From 7d47e18906ece8a959f1841e4f866e873c9f9fac Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 00:47:48 +0900 Subject: [PATCH 01/33] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=EC=9D=B8=EB=AA=85=EB=B6=80=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 + .../club/controller/ClubMemberSheetApi.java | 41 ++++++++ .../controller/ClubMemberSheetController.java | 34 +++++++ .../club/dto/ClubMemberSheetSyncRequest.java | 14 +++ .../club/dto/ClubMemberSheetSyncResponse.java | 19 ++++ .../club/service/ClubMemberSheetService.java | 95 +++++++++++++++++++ .../konect/global/code/ApiResponseCode.java | 1 + .../googlesheets/GoogleSheetsConfig.java | 40 ++++++++ .../googlesheets/GoogleSheetsProperties.java | 10 ++ .../resources/application-infrastructure.yml | 5 + 10 files changed, 263 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java create mode 100644 src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java diff --git a/build.gradle b/build.gradle index d854c5a0..c71d2a8a 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,10 @@ dependencies { // notification implementation 'com.google.firebase:firebase-admin:9.2.0' + // Google Sheets API + implementation 'com.google.apis:google-api-services-sheets:v4-rev20251215-2.0.0' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' + // Gemini AI - using REST API directly (no SDK dependency) // test diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java new file mode 100644 index 00000000..6bff31fd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -0,0 +1,41 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet: 인명부 시트 동기화") +@RequestMapping("/clubs") +public interface ClubMemberSheetApi { + + @Operation(summary = "동아리 인명부를 구글 스프레드시트로 내보낸다.", description = """ + 동아리 운영진 이상만 인명부를 구글 스프레드시트로 내보낼 수 있습니다. + 기존 시트 데이터를 초기화하고 현재 DB 기준 전체 회원 목록을 덮어씁니다. + + ## 시트 컬럼 순서 + 이름 | 학번 | 이메일 | 전화번호 | 직책 | 가입일 + + ## 사전 조건 + - 서비스 계정 이메일을 해당 스프레드시트에 편집자로 공유해야 합니다. + + ## 에러 + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. + - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. + - FAILED_SYNC_GOOGLE_SHEET (500): 구글 스프레드시트 동기화에 실패했습니다. + """) + @PostMapping("/{clubId}/members/sheet-sync") + ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubMemberSheetSyncRequest request, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java new file mode 100644 index 00000000..adc62c7a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -0,0 +1,34 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +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.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.service.ClubMemberSheetService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubMemberSheetController implements ClubMemberSheetApi { + + private final ClubMemberSheetService clubMemberSheetService; + + @Override + public ResponseEntity syncMembersToSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubMemberSheetSyncRequest request, + @UserId Integer requesterId + ) { + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( + clubId, requesterId, request + ); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java new file mode 100644 index 00000000..800f4791 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record ClubMemberSheetSyncRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Schema( + description = "동기화 대상 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java new file mode 100644 index 00000000..886892b6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubMemberSheetSyncResponse.java @@ -0,0 +1,19 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubMemberSheetSyncResponse( + @Schema(description = "동기화된 회원 수", example = "42") + int syncedMemberCount, + + @Schema( + description = "동기화된 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit" + ) + String sheetUrl +) { + public static ClubMemberSheetSyncResponse of(int syncedMemberCount, String spreadsheetId) { + String sheetUrl = "https://docs.google.com/spreadsheets/d/" + spreadsheetId + "/edit"; + return new ClubMemberSheetSyncResponse(syncedMemberCount, sheetUrl); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java new file mode 100644 index 00000000..ef7cf2d6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -0,0 +1,95 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET; + +import java.io.IOException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClubMemberSheetService { + + private static final String SHEET_RANGE = "A1"; + private static final String CLEAR_RANGE = "A:F"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List HEADER_ROW = List.of( + "이름", "학번", "이메일", "전화번호", "직책", "가입일" + ); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId, + ClubMemberSheetSyncRequest request + ) { + clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + List members = clubMemberRepository.findAllByClubId(clubId); + String spreadsheetId = request.spreadsheetId(); + + try { + clearSheet(spreadsheetId); + writeSheet(spreadsheetId, buildRows(members)); + } catch (IOException e) { + throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); + } + + return ClubMemberSheetSyncResponse.of(members.size(), spreadsheetId); + } + + private void clearSheet(String spreadsheetId) throws IOException { + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, CLEAR_RANGE, new ClearValuesRequest()) + .execute(); + } + + private void writeSheet(String spreadsheetId, List> rows) throws IOException { + ValueRange body = new ValueRange().setValues(rows); + googleSheetsService.spreadsheets().values() + .update(spreadsheetId, SHEET_RANGE, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } + + private List> buildRows(List members) { + List> rows = new ArrayList<>(); + rows.add(HEADER_ROW); + + for (ClubMember member : members) { + rows.add(List.of( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + member.getUser().getPhoneNumber() != null ? member.getUser().getPhoneNumber() : "", + member.getClubPosition().getDescription(), + member.getCreatedAt().format(DATE_FORMATTER) + )); + } + + return rows; + } +} 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 ed6b7ab4..cdf18a92 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -115,6 +115,7 @@ public enum ApiResponseCode { // 500 Internal Server Error (서버 오류) CLIENT_ABORTED(HttpStatus.INTERNAL_SERVER_ERROR, "클라이언트에 의해 연결이 중단되었습니다."), FAILED_UPLOAD_FILE(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), + FAILED_SYNC_GOOGLE_SHEET(HttpStatus.INTERNAL_SERVER_ERROR, "구글 스프레드시트 동기화에 실패했습니다."), FAILED_SEND_NOTIFICATION(HttpStatus.INTERNAL_SERVER_ERROR, "알림 전송에 실패했습니다."), UNEXPECTED_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 예기치 못한 에러가 발생했습니다."); diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java new file mode 100644 index 00000000..fb8b6da8 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -0,0 +1,40 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.SheetsScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class GoogleSheetsConfig { + + private final GoogleSheetsProperties googleSheetsProperties; + + @Bean + public Sheets googleSheetsService() throws IOException, GeneralSecurityException { + InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath()); + GoogleCredentials credentials = GoogleCredentials.fromStream(in) + .createScoped(Collections.singleton(SheetsScopes.SPREADSHEETS)); + + return new Sheets.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java new file mode 100644 index 00000000..b8cd5882 --- /dev/null +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsProperties.java @@ -0,0 +1,10 @@ +package gg.agit.konect.infrastructure.googlesheets; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "google.sheets") +public record GoogleSheetsProperties( + String credentialsPath, + String applicationName +) { +} diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index b44991e1..7e3da81d 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -20,3 +20,8 @@ claude: mcp: url: ${MCP_BRIDGE_URL:http://localhost:3100} + +google: + sheets: + credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} + application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} From 75e88e0da0dc9364e83e8784df90adb74af6ed5d Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 00:54:36 +0900 Subject: [PATCH 02/33] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20=EB=8F=99?= =?UTF-8?q?=EC=95=84=EB=A6=AC=20=EC=9D=B8=EB=AA=85=EB=B6=80=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index c71d2a8a..72fcf961 100644 --- a/build.gradle +++ b/build.gradle @@ -76,7 +76,7 @@ dependencies { implementation 'com.google.firebase:firebase-admin:9.2.0' // Google Sheets API - implementation 'com.google.apis:google-api-services-sheets:v4-rev20251215-2.0.0' + implementation 'com.google.apis:google-api-services-sheets:v4-rev20251110-2.0.0' implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' // Gemini AI - using REST API directly (no SDK dependency) From d51b6ff11c095018eaa0970739226f154e33c003 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 02:45:39 +0900 Subject: [PATCH 03/33] =?UTF-8?q?fix:=20=EC=A0=84=ED=99=94=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=95=9E=200=20=EB=88=84=EB=9D=BD=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/club/service/ClubMemberSheetService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index ef7cf2d6..e1634ef7 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -21,7 +21,9 @@ import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -55,6 +57,7 @@ public ClubMemberSheetSyncResponse syncMembersToSheet( clearSheet(spreadsheetId); writeSheet(spreadsheetId, buildRows(members)); } catch (IOException e) { + log.error("구글 스프레드시트 동기화 실패. spreadsheetId={}, cause={}", spreadsheetId, e.getMessage(), e); throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); } @@ -84,7 +87,7 @@ private List> buildRows(List members) { member.getUser().getName(), member.getUser().getStudentNumber(), member.getUser().getEmail(), - member.getUser().getPhoneNumber() != null ? member.getUser().getPhoneNumber() : "", + member.getUser().getPhoneNumber() != null ? "'" + member.getUser().getPhoneNumber() : "", member.getClubPosition().getDescription(), member.getCreatedAt().format(DATE_FORMATTER) )); From 3a93a1a8fbfc1d814eebe517ab24e4f960edffe3 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 09:31:28 +0900 Subject: [PATCH 04/33] =?UTF-8?q?feat:=20=EB=8F=99=EC=95=84=EB=A6=AC?= =?UTF-8?q?=EB=B3=84=20=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20ID=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20sync=20API=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubMemberSheetApi.java | 47 +++++++++++++------ .../controller/ClubMemberSheetController.java | 17 +++++-- .../club/dto/ClubSheetIdUpdateRequest.java | 14 ++++++ .../agit/konect/domain/club/model/Club.java | 7 +++ .../club/service/ClubMemberSheetService.java | 29 ++++++++---- .../konect/global/code/ApiResponseCode.java | 1 + .../V50__add_google_sheet_id_to_club.sql | 2 + 7 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java create mode 100644 src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index 6bff31fd..ba356b20 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -3,39 +3,58 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -@Tag(name = "(Normal) Club - Sheet: 인명부 시트 동기화") +@Tag(name = "(Normal) Club - Sheet: \uc778\uba85\ubd80 \uc2dc\ud2b8 \ub3d9\uae30\ud654") @RequestMapping("/clubs") public interface ClubMemberSheetApi { - @Operation(summary = "동아리 인명부를 구글 스프레드시트로 내보낸다.", description = """ - 동아리 운영진 이상만 인명부를 구글 스프레드시트로 내보낼 수 있습니다. - 기존 시트 데이터를 초기화하고 현재 DB 기준 전체 회원 목록을 덮어씁니다. + @Operation(summary = "\ub3d9\uc544\ub9ac \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\ub97c \ub4f1\ub85d/\uc218\uc815\ud55c\ub2e4.", description = """ + \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub4f1\ub85d\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. + \uc774\ud6c4 \uc778\uba85\ubd80 \ub3d9\uae30\ud654 API \ud638\uc6b4 \uc2dc \uc800\uc7a5\ub41c ID\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. - ## 시트 컬럼 순서 - 이름 | 학번 | 이메일 | 전화번호 | 직책 | 가입일 + ## \uc0ac\uc804 \uc870\uac74 + - \uc11c\ube44\uc2a4 \uacc4\uc815 \uc774\uba54\uc77c\uc744 \ud574\ub2f9 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\uc5d0 \ud3b8\uc9d1\uc790\ub85c \uacf5\uc720\ud574\uc57c \ud569\ub2c8\ub2e4. - ## 사전 조건 - - 서비스 계정 이메일을 해당 스프레드시트에 편집자로 공유해야 합니다. + ## \uc5d0\ub7ec + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. + """) + @PutMapping("/{clubId}/sheet") + ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ); + + @Operation(summary = "\ub3d9\uc544\ub9ac \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0b8\ub2e4.", description = """ + \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4. + \uae30\uc874 \uc2dc\ud2b8 \ub370\uc774\ud130\ub97c \ucd08\uae30\ud654\ud558\uace0 \ud604\uc7ac DB \uae30\uc900 \uc804\uccb4 \ud68c\uc6d0 \ubaa9\ub85d\uc744 \ub36e\uc5b4\uc37c\ub2c8\ub2e4. + + ## \uc2dc\ud2b8 \ucf5c\ub7fc \uc21c\uc11c + \uc774\ub984 | \ud559\ubc88 | \uc774\uba54\uc77c | \uc804\ud654\ubc88\ud638 | \uc9c1\uccb8 | \uac00\uc785\uc77c + + ## \uc0ac\uc804 \uc870\uac74 + - \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \ub3d9\uc544\ub9ac\uc5d0 \ub4f1\ub85d\ub418\uc5b4 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4. - ## 에러 - - FORBIDDEN_CLUB_MANAGER_ACCESS (403): 동아리 매니저 권한이 없습니다. - - NOT_FOUND_CLUB (404): 동아리를 찾을 수 없습니다. - - FAILED_SYNC_GOOGLE_SHEET (500): 구글 스프레드시트 동기화에 실패했습니다. + ## \uc5d0\ub7ec + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB_SHEET_ID (404): \ub4f1\ub85d\ub41c \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. + - FAILED_SYNC_GOOGLE_SHEET (500): \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. """) @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody ClubMemberSheetSyncRequest request, @UserId Integer requesterId ); } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java index adc62c7a..78889676 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -6,8 +6,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.domain.club.service.ClubMemberSheetService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -20,15 +20,22 @@ public class ClubMemberSheetController implements ClubMemberSheetApi { private final ClubMemberSheetService clubMemberSheetService; + @Override + public ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ) { + clubMemberSheetService.updateSheetId(clubId, requesterId, request); + return ResponseEntity.ok().build(); + } + @Override public ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, - @Valid @RequestBody ClubMemberSheetSyncRequest request, @UserId Integer requesterId ) { - ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet( - clubId, requesterId, request - ); + ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet(clubId, requesterId); return ResponseEntity.ok(response); } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java new file mode 100644 index 00000000..339a9433 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record ClubSheetIdUpdateRequest( + @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Schema( + description = "등록할 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", + example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 3e946392..14762f86 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -84,6 +84,9 @@ public class Club extends BaseEntity { @Column(name = "is_application_enabled") private Boolean isApplicationEnabled; + @Column(name = "google_sheet_id", length = 255) + private String googleSheetId; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -224,4 +227,8 @@ private void clearFeeInfo() { this.feeAccountNumber = null; this.feeAccountHolder = null; } + + public void updateGoogleSheetId(String googleSheetId) { + this.googleSheetId = googleSheetId; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index e1634ef7..05d62170 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.club.service; import static gg.agit.konect.global.code.ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET; +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; import java.io.IOException; import java.time.format.DateTimeFormatter; @@ -14,8 +15,9 @@ import com.google.api.services.sheets.v4.model.ClearValuesRequest; import com.google.api.services.sheets.v4.model.ValueRange; -import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncRequest; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; @@ -34,7 +36,7 @@ public class ClubMemberSheetService { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private static final List HEADER_ROW = List.of( - "이름", "학번", "이메일", "전화번호", "직책", "가입일" + "\uc774\ub984", "\ud559\ubc88", "\uc774\uba54\uc77c", "\uc804\ud654\ubc88\ud638", "\uc9c1\uccb8", "\uac00\uc785\uc77c" ); private final Sheets googleSheetsService; @@ -42,22 +44,29 @@ public class ClubMemberSheetService { private final ClubMemberRepository clubMemberRepository; private final ClubPermissionValidator clubPermissionValidator; - public ClubMemberSheetSyncResponse syncMembersToSheet( - Integer clubId, - Integer requesterId, - ClubMemberSheetSyncRequest request - ) { - clubRepository.getById(clubId); + @Transactional + public void updateSheetId(Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request) { + Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); + club.updateGoogleSheetId(request.spreadsheetId()); + } + + public ClubMemberSheetSyncResponse syncMembersToSheet(Integer clubId, Integer requesterId) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + throw CustomException.of(NOT_FOUND_CLUB_SHEET_ID); + } List members = clubMemberRepository.findAllByClubId(clubId); - String spreadsheetId = request.spreadsheetId(); try { clearSheet(spreadsheetId); writeSheet(spreadsheetId, buildRows(members)); } catch (IOException e) { - log.error("구글 스프레드시트 동기화 실패. spreadsheetId={}, cause={}", spreadsheetId, e.getMessage(), e); + log.error("\uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654 \uc2e4\ud328. spreadsheetId={}, cause={}", spreadsheetId, e.getMessage(), e); throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); } 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 cdf18a92..33359c58 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -91,6 +91,7 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), diff --git a/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql b/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql new file mode 100644 index 00000000..722c7178 --- /dev/null +++ b/src/main/resources/db/migration/V50__add_google_sheet_id_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN google_sheet_id VARCHAR(255) NULL; From 32075169fa8f835142f59895fbca5ad828a7ca95 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 09:34:02 +0900 Subject: [PATCH 05/33] =?UTF-8?q?fix:=20Checkstyle=20120=EC=9E=90=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=EB=9D=BC=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubMemberSheetApi.java | 57 ++++++++++--------- .../club/service/ClubMemberSheetService.java | 8 ++- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index ba356b20..3703adb5 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -18,17 +18,20 @@ @RequestMapping("/clubs") public interface ClubMemberSheetApi { - @Operation(summary = "\ub3d9\uc544\ub9ac \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\ub97c \ub4f1\ub85d/\uc218\uc815\ud55c\ub2e4.", description = """ - \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub4f1\ub85d\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. - \uc774\ud6c4 \uc778\uba85\ubd80 \ub3d9\uae30\ud654 API \ud638\uc6b4 \uc2dc \uc800\uc7a5\ub41c ID\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. - - ## \uc0ac\uc804 \uc870\uac74 - - \uc11c\ube44\uc2a4 \uacc4\uc815 \uc774\uba54\uc77c\uc744 \ud574\ub2f9 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\uc5d0 \ud3b8\uc9d1\uc790\ub85c \uacf5\uc720\ud574\uc57c \ud569\ub2c8\ub2e4. - - ## \uc5d0\ub7ec - - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. - """) + @Operation( + summary = "\ub3d9\uc544\ub9ac \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\ub97c \ub4f1\ub85d/\uc218\uc815\ud55c\ub2e4.", + description = """ + \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub4f1\ub85d\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. + \uc774\ud6c4 \uc778\uba85\ubd80 \ub3d9\uae30\ud654 API \ud638\ucd9c \uc2dc \uc800\uc7a5\ub41c ID\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. + + ## \uc0ac\uc804 \uc870\uac74 + - \uc11c\ube44\uc2a4 \uacc4\uc815 \uc774\uba54\uc77c\uc744 \ud574\ub2f9 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\uc5d0 \ud3b8\uc9d1\uc790\ub85c \uacf5\uc720\ud574\uc57c \ud569\ub2c8\ub2e4. + + ## \uc5d0\ub7ec + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. + """ + ) @PutMapping("/{clubId}/sheet") ResponseEntity updateSheetId( @PathVariable(name = "clubId") Integer clubId, @@ -36,22 +39,22 @@ ResponseEntity updateSheetId( @UserId Integer requesterId ); - @Operation(summary = "\ub3d9\uc544\ub9ac \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0b8\ub2e4.", description = """ - \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4. - \uae30\uc874 \uc2dc\ud2b8 \ub370\uc774\ud130\ub97c \ucd08\uae30\ud654\ud558\uace0 \ud604\uc7ac DB \uae30\uc900 \uc804\uccb4 \ud68c\uc6d0 \ubaa9\ub85d\uc744 \ub36e\uc5b4\uc37c\ub2c8\ub2e4. - - ## \uc2dc\ud2b8 \ucf5c\ub7fc \uc21c\uc11c - \uc774\ub984 | \ud559\ubc88 | \uc774\uba54\uc77c | \uc804\ud654\ubc88\ud638 | \uc9c1\uccb8 | \uac00\uc785\uc77c - - ## \uc0ac\uc804 \uc870\uac74 - - \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \ub3d9\uc544\ub9ac\uc5d0 \ub4f1\ub85d\ub418\uc5b4 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4. - - ## \uc5d0\ub7ec - - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB_SHEET_ID (404): \ub4f1\ub85d\ub41c \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. - - FAILED_SYNC_GOOGLE_SHEET (500): \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. - """) + @Operation( + summary = "\ub3d9\uc544\ub9ac \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0b8\ub2e4.", + description = """ + \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub0b4\ubcf4\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4. + \uae30\uc874 \uc2dc\ud2b8 \ub370\uc774\ud130\ub97c \ucd08\uae30\ud654\ud558\uace0 \ud604\uc7ac DB \uae30\uc900 \uc804\uccb4 \ud68c\uc6d0 \ubaa9\ub85d\uc744 \ub36e\uc5b4\uc37c\ub2c8\ub2e4. + + ## \uc2dc\ud2b8 \ucf5c\ub7fc \uc21c\uc11c + \uc774\ub984 | \ud559\ubc88 | \uc774\uba54\uc77c | \uc804\ud654\ubc88\ud638 | \uc9c1\uccb8 | \uac00\uc785\uc77c + + ## \uc5d0\ub7ec + - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. + - NOT_FOUND_CLUB_SHEET_ID (404): \ub4f1\ub85d\ub41c \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. + - FAILED_SYNC_GOOGLE_SHEET (500): \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. + """ + ) @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 05d62170..62ed20bd 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -66,7 +66,10 @@ public ClubMemberSheetSyncResponse syncMembersToSheet(Integer clubId, Integer re clearSheet(spreadsheetId); writeSheet(spreadsheetId, buildRows(members)); } catch (IOException e) { - log.error("\uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654 \uc2e4\ud328. spreadsheetId={}, cause={}", spreadsheetId, e.getMessage(), e); + log.error( + "\uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654 \uc2e4\ud328. spreadsheetId={}, cause={}", + spreadsheetId, e.getMessage(), e + ); throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); } @@ -96,7 +99,8 @@ private List> buildRows(List members) { member.getUser().getName(), member.getUser().getStudentNumber(), member.getUser().getEmail(), - member.getUser().getPhoneNumber() != null ? "'" + member.getUser().getPhoneNumber() : "", + member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : "", member.getClubPosition().getDescription(), member.getCreatedAt().format(DATE_FORMATTER) )); From d03fa987ec8df8256739c1080cda798dc0e4aca8 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 09:35:19 +0900 Subject: [PATCH 06/33] =?UTF-8?q?fix:=20Checkstyle=20120=EC=9E=90=20?= =?UTF-8?q?=EC=B4=88=EA=B3=BC=20=ED=95=B4=EA=B2=B0=20-=20Swagger=20descrip?= =?UTF-8?q?tion=20=EC=98=81=EC=96=B4=20=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubMemberSheetApi.java | 34 ++----------------- .../club/service/ClubMemberSheetService.java | 21 ++++++++---- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index 3703adb5..a0d29ff0 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -14,24 +14,11 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -@Tag(name = "(Normal) Club - Sheet: \uc778\uba85\ubd80 \uc2dc\ud2b8 \ub3d9\uae30\ud654") +@Tag(name = "(Normal) Club - Sheet") @RequestMapping("/clubs") public interface ClubMemberSheetApi { - @Operation( - summary = "\ub3d9\uc544\ub9ac \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\ub97c \ub4f1\ub85d/\uc218\uc815\ud55c\ub2e4.", - description = """ - \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub4f1\ub85d\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4. - \uc774\ud6c4 \uc778\uba85\ubd80 \ub3d9\uae30\ud654 API \ud638\ucd9c \uc2dc \uc800\uc7a5\ub41c ID\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4. - - ## \uc0ac\uc804 \uc870\uac74 - - \uc11c\ube44\uc2a4 \uacc4\uc815 \uc774\uba54\uc77c\uc744 \ud574\ub2f9 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\uc5d0 \ud3b8\uc9d1\uc790\ub85c \uacf5\uc720\ud574\uc57c \ud569\ub2c8\ub2e4. - - ## \uc5d0\ub7ec - - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. - """ - ) + @Operation(summary = "Register or update the Google Spreadsheet ID for a club.") @PutMapping("/{clubId}/sheet") ResponseEntity updateSheetId( @PathVariable(name = "clubId") Integer clubId, @@ -39,22 +26,7 @@ ResponseEntity updateSheetId( @UserId Integer requesterId ); - @Operation( - summary = "\ub3d9\uc544\ub9ac \uc778\uba85\ubd80\ub97c \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8\ub85c \ub0b4\ubcf4\ub0b8\ub2e4.", - description = """ - \ub3d9\uc544\ub9ac \uc6b4\uc601\uc9c4 \uc774\uc0c1\ub9cc \ub0b4\ubcf4\ub0bc \uc218 \uc788\uc2b5\ub2c8\ub2e4. - \uae30\uc874 \uc2dc\ud2b8 \ub370\uc774\ud130\ub97c \ucd08\uae30\ud654\ud558\uace0 \ud604\uc7ac DB \uae30\uc900 \uc804\uccb4 \ud68c\uc6d0 \ubaa9\ub85d\uc744 \ub36e\uc5b4\uc37c\ub2c8\ub2e4. - - ## \uc2dc\ud2b8 \ucf5c\ub7fc \uc21c\uc11c - \uc774\ub984 | \ud559\ubc88 | \uc774\uba54\uc77c | \uc804\ud654\ubc88\ud638 | \uc9c1\uccb8 | \uac00\uc785\uc77c - - ## \uc5d0\ub7ec - - FORBIDDEN_CLUB_MANAGER_ACCESS (403): \ub3d9\uc544\ub9ac \ub9e4\ub2c8\uc800 \uad8c\ud55c\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB (404): \ub3d9\uc544\ub9ac\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. - - NOT_FOUND_CLUB_SHEET_ID (404): \ub4f1\ub85d\ub41c \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 ID\uac00 \uc5c6\uc2b5\ub2c8\ub2e4. - - FAILED_SYNC_GOOGLE_SHEET (500): \uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. - """ - ) + @Operation(summary = "Export club member list to the registered Google Spreadsheet.") @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 62ed20bd..b5bbfdbf 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -33,11 +33,11 @@ public class ClubMemberSheetService { private static final String SHEET_RANGE = "A1"; private static final String CLEAR_RANGE = "A:F"; - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); - private static final List HEADER_ROW = List.of( - "\uc774\ub984", "\ud559\ubc88", "\uc774\uba54\uc77c", "\uc804\ud654\ubc88\ud638", "\uc9c1\uccb8", "\uac00\uc785\uc77c" - ); + private static final List HEADER_ROW = + List.of("Name", "StudentId", "Email", "Phone", "Position", "JoinedAt"); private final Sheets googleSheetsService; private final ClubRepository clubRepository; @@ -45,13 +45,20 @@ public class ClubMemberSheetService { private final ClubPermissionValidator clubPermissionValidator; @Transactional - public void updateSheetId(Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request) { + public void updateSheetId( + Integer clubId, + Integer requesterId, + ClubSheetIdUpdateRequest request + ) { Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); club.updateGoogleSheetId(request.spreadsheetId()); } - public ClubMemberSheetSyncResponse syncMembersToSheet(Integer clubId, Integer requesterId) { + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId + ) { Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); @@ -67,7 +74,7 @@ public ClubMemberSheetSyncResponse syncMembersToSheet(Integer clubId, Integer re writeSheet(spreadsheetId, buildRows(members)); } catch (IOException e) { log.error( - "\uad6c\uae00 \uc2a4\ud504\ub808\ub4dc\uc2dc\ud2b8 \ub3d9\uae30\ud654 \uc2e4\ud328. spreadsheetId={}, cause={}", + "Google Sheets sync failed. spreadsheetId={}, cause={}", spreadsheetId, e.getMessage(), e ); throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); From 6535eeee124ea774d3efbe769cd391f59011e94d Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 09:38:24 +0900 Subject: [PATCH 07/33] =?UTF-8?q?feat:=20=EA=B0=80=EC=9E=85=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8/=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EA=B5=AC=EA=B8=80?= =?UTF-8?q?=20=EC=8B=9C=ED=8A=B8=20=EC=9E=90=EB=8F=99=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/event/ClubMemberChangedEvent.java | 9 +++++++ .../club/service/ClubApplicationService.java | 2 ++ .../service/ClubMemberManagementService.java | 4 +++ .../club/service/ClubMemberSheetService.java | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java new file mode 100644 index 00000000..2afa0b67 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubMemberChangedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.event; + +public record ClubMemberChangedEvent( + Integer clubId +) { + public static ClubMemberChangedEvent of(Integer clubId) { + return new ClubMemberChangedEvent(clubId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java index 076d7c69..9cc2a9d3 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubApplicationService.java @@ -31,6 +31,7 @@ import gg.agit.konect.domain.club.dto.ClubFeeInfoResponse; import gg.agit.konect.domain.club.event.ClubApplicationApprovedEvent; import gg.agit.konect.domain.club.event.ClubApplicationSubmittedEvent; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubApply; import gg.agit.konect.domain.club.model.ClubApplyAnswer; @@ -251,6 +252,7 @@ public void approveClubApplication(Integer clubId, Integer applicationId, Intege clubId, club.getName() )); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } @Transactional diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 5a83be74..9b1a6ed9 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +19,7 @@ import gg.agit.konect.domain.club.dto.PresidentTransferRequest; import gg.agit.konect.domain.club.dto.VicePresidentChangeRequest; import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.ClubPreMember; @@ -42,6 +44,7 @@ public class ClubMemberManagementService { private final ClubPermissionValidator clubPermissionValidator; private final UserRepository userRepository; private final ChatRoomMembershipService chatRoomMembershipService; + private final ApplicationEventPublisher applicationEventPublisher; @Transactional public ClubMember changeMemberPosition( @@ -273,6 +276,7 @@ public void removeMember(Integer clubId, Integer targetUserId, Integer requester clubMemberRepository.delete(target); chatRoomMembershipService.removeClubMember(clubId, targetUserId); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } private void validateNotSelf(Integer userId1, Integer userId2, ApiResponseCode errorCode) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index b5bbfdbf..a7a1f071 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -15,8 +15,12 @@ import com.google.api.services.sheets.v4.model.ClearValuesRequest; import com.google.api.services.sheets.v4.model.ValueRange; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; @@ -44,6 +48,27 @@ public class ClubMemberSheetService { private final ClubMemberRepository clubMemberRepository; private final ClubPermissionValidator clubPermissionValidator; + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onClubMemberChanged(ClubMemberChangedEvent event) { + Club club = clubRepository.getById(event.clubId()); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + List members = clubMemberRepository.findAllByClubId(event.clubId()); + + try { + clearSheet(spreadsheetId); + writeSheet(spreadsheetId, buildRows(members)); + } catch (IOException e) { + log.error( + "Auto sheet sync failed. clubId={}, spreadsheetId={}, cause={}", + event.clubId(), spreadsheetId, e.getMessage(), e + ); + } + } + @Transactional public void updateSheetId( Integer clubId, From b0604ca64a5a84fca041a31fc3bf8d1d122862e8 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 10:04:54 +0900 Subject: [PATCH 08/33] =?UTF-8?q?feat:=20=ED=9A=8C=EB=B9=84=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80=20=EC=9E=A5=EB=B6=80=20=EC=9E=90=EB=8F=99=ED=99=94=20?= =?UTF-8?q?-=20Async/Debounce=20=EC=8B=9C=ED=8A=B8=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubFeePaymentApi.java | 51 +++++ .../controller/ClubFeePaymentController.java | 63 ++++++ .../club/controller/ClubMemberSheetApi.java | 4 + .../controller/ClubMemberSheetController.java | 7 +- .../club/dto/ClubFeePaymentResponse.java | 37 ++++ .../club/dto/ClubFeePaymentSubmitRequest.java | 9 + .../domain/club/enums/ClubSheetSortKey.java | 9 + .../event/ClubFeePaymentApprovedEvent.java | 9 + .../domain/club/model/ClubFeePayment.java | 75 +++++++ .../repository/ClubFeePaymentRepository.java | 42 ++++ .../club/service/ClubFeePaymentService.java | 92 +++++++++ .../club/service/ClubMemberSheetService.java | 99 ++-------- .../club/service/SheetSyncDebouncer.java | 42 ++++ .../club/service/SheetSyncExecutor.java | 183 ++++++++++++++++++ .../konect/global/code/ApiResponseCode.java | 3 + .../konect/global/config/AsyncConfig.java | 22 +++ .../V51__add_club_fee_payment_table.sql | 16 ++ 17 files changed, 678 insertions(+), 85 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java create mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java create mode 100644 src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java create mode 100644 src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java create mode 100644 src/main/java/gg/agit/konect/global/config/AsyncConfig.java create mode 100644 src/main/resources/db/migration/V51__add_club_fee_payment_table.sql diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java new file mode 100644 index 00000000..c3a54fa6 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java @@ -0,0 +1,51 @@ +package gg.agit.konect.domain.club.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; +import gg.agit.konect.domain.club.dto.ClubFeePaymentSubmitRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Club - FeePayment") +@RequestMapping("/clubs") +public interface ClubFeePaymentApi { + + @Operation(summary = "Submit club fee payment.") + @PostMapping("/{clubId}/fee-payments") + ResponseEntity submitFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @RequestBody ClubFeePaymentSubmitRequest request, + @UserId Integer requesterId + ); + + @Operation(summary = "Approve a member's fee payment. Manager only.") + @PostMapping("/{clubId}/fee-payments/{targetUserId}/approve") + ResponseEntity approveFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "targetUserId") Integer targetUserId, + @UserId Integer requesterId + ); + + @Operation(summary = "Get all fee payments for a club. Manager only.") + @GetMapping("/{clubId}/fee-payments") + ResponseEntity> getFeePayments( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ); + + @Operation(summary = "Get my fee payment status.") + @GetMapping("/{clubId}/fee-payments/me") + ResponseEntity getMyFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java new file mode 100644 index 00000000..99ebebea --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java @@ -0,0 +1,63 @@ +package gg.agit.konect.domain.club.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +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.RestController; + +import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; +import gg.agit.konect.domain.club.dto.ClubFeePaymentSubmitRequest; +import gg.agit.konect.domain.club.service.ClubFeePaymentService; +import gg.agit.konect.global.auth.annotation.UserId; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubFeePaymentController implements ClubFeePaymentApi { + + private final ClubFeePaymentService clubFeePaymentService; + + @Override + public ResponseEntity submitFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @RequestBody ClubFeePaymentSubmitRequest request, + @UserId Integer requesterId + ) { + ClubFeePaymentResponse response = clubFeePaymentService.submitFeePayment( + clubId, requesterId, request.paymentImageUrl() + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity approveFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "targetUserId") Integer targetUserId, + @UserId Integer requesterId + ) { + ClubFeePaymentResponse response = clubFeePaymentService.approveFeePayment( + clubId, targetUserId, requesterId + ); + return ResponseEntity.ok(response); + } + + @Override + public ResponseEntity> getFeePayments( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ) { + return ResponseEntity.ok(clubFeePaymentService.getFeePayments(clubId, requesterId)); + } + + @Override + public ResponseEntity getMyFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ) { + return ResponseEntity.ok(clubFeePaymentService.getMyFeePayment(clubId, requesterId)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index a0d29ff0..a33819e6 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -6,9 +6,11 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.global.auth.annotation.UserId; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -30,6 +32,8 @@ ResponseEntity updateSheetId( @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, @UserId Integer requesterId ); } diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java index 78889676..ad374cc8 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -4,10 +4,12 @@ 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.RestController; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.service.ClubMemberSheetService; import gg.agit.konect.global.auth.annotation.UserId; import jakarta.validation.Valid; @@ -33,9 +35,12 @@ public ResponseEntity updateSheetId( @Override public ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, @UserId Integer requesterId ) { - ClubMemberSheetSyncResponse response = clubMemberSheetService.syncMembersToSheet(clubId, requesterId); + ClubMemberSheetSyncResponse response = + clubMemberSheetService.syncMembersToSheet(clubId, requesterId, sortKey, ascending); return ResponseEntity.ok(response); } } diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java new file mode 100644 index 00000000..76784c33 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java @@ -0,0 +1,37 @@ +package gg.agit.konect.domain.club.dto; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.club.model.ClubFeePayment; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubFeePaymentResponse( + @Schema(description = "User ID", example = "1") + Integer userId, + + @Schema(description = "Name", example = "John") + String userName, + + @Schema(description = "Student number", example = "2021136089") + String studentNumber, + + @Schema(description = "Payment status", example = "true") + boolean isPaid, + + @Schema(description = "Approved at") + LocalDateTime approvedAt, + + @Schema(description = "Payment image URL") + String paymentImageUrl +) { + public static ClubFeePaymentResponse from(ClubFeePayment payment) { + return new ClubFeePaymentResponse( + payment.getUser().getId(), + payment.getUser().getName(), + payment.getUser().getStudentNumber(), + payment.isPaid(), + payment.getApprovedAt(), + payment.getPaymentImageUrl() + ); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java new file mode 100644 index 00000000..68c8d3fd --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record ClubFeePaymentSubmitRequest( + @Schema(description = "Payment image URL", example = "https://cdn.konect.com/fee/abc.jpg") + String paymentImageUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java new file mode 100644 index 00000000..d859affa --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.enums; + +public enum ClubSheetSortKey { + NAME, + STUDENT_ID, + POSITION, + JOINED_AT, + FEE_PAID +} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java new file mode 100644 index 00000000..bcd79126 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.event; + +public record ClubFeePaymentApprovedEvent( + Integer clubId +) { + public static ClubFeePaymentApprovedEvent of(Integer clubId) { + return new ClubFeePaymentApprovedEvent(clubId); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java b/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java new file mode 100644 index 00000000..b13fa14b --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java @@ -0,0 +1,75 @@ +package gg.agit.konect.domain.club.model; + +import static jakarta.persistence.FetchType.LAZY; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +import java.time.LocalDateTime; + +import gg.agit.konect.domain.user.model.User; +import gg.agit.konect.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "club_fee_payment") +@NoArgsConstructor(access = PROTECTED) +public class ClubFeePayment extends BaseEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "id", nullable = false, updatable = false) + private Integer id; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "club_id", nullable = false, updatable = false) + private Club club; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "user_id", nullable = false, updatable = false) + private User user; + + @Column(name = "is_paid", nullable = false) + private boolean isPaid; + + @Column(name = "payment_image_url") + private String paymentImageUrl; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "approved_by") + private User approvedBy; + + @Builder + private ClubFeePayment(Club club, User user, String paymentImageUrl) { + this.club = club; + this.user = user; + this.isPaid = false; + this.paymentImageUrl = paymentImageUrl; + } + + public static ClubFeePayment of(Club club, User user, String paymentImageUrl) { + return ClubFeePayment.builder() + .club(club) + .user(user) + .paymentImageUrl(paymentImageUrl) + .build(); + } + + public void approve(User approver) { + this.isPaid = true; + this.approvedAt = LocalDateTime.now(); + this.approvedBy = approver; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java new file mode 100644 index 00000000..f9cf7511 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.club.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import gg.agit.konect.domain.club.model.ClubFeePayment; +import gg.agit.konect.global.code.ApiResponseCode; +import gg.agit.konect.global.exception.CustomException; + +public interface ClubFeePaymentRepository extends Repository { + + ClubFeePayment save(ClubFeePayment clubFeePayment); + + @Query(""" + SELECT fp + FROM ClubFeePayment fp + JOIN FETCH fp.user + WHERE fp.club.id = :clubId + AND fp.user.id = :userId + """) + Optional findByClubIdAndUserId( + @Param("clubId") Integer clubId, + @Param("userId") Integer userId + ); + + default ClubFeePayment getByClubIdAndUserId(Integer clubId, Integer userId) { + return findByClubIdAndUserId(clubId, userId) + .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_FEE_PAYMENT)); + } + + @Query(""" + SELECT fp + FROM ClubFeePayment fp + JOIN FETCH fp.user + WHERE fp.club.id = :clubId + """) + List findAllByClubId(@Param("clubId") Integer clubId); +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java new file mode 100644 index 00000000..7edd01d7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java @@ -0,0 +1,92 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_FEE_PAYMENT; + +import java.util.List; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; +import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubFeePayment; +import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; +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.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClubFeePaymentService { + + private final ClubRepository clubRepository; + private final ClubFeePaymentRepository clubFeePaymentRepository; + private final UserRepository userRepository; + private final ClubPermissionValidator clubPermissionValidator; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public ClubFeePaymentResponse submitFeePayment( + Integer clubId, + Integer userId, + String paymentImageUrl + ) { + Club club = clubRepository.getById(clubId); + User user = userRepository.getById(userId); + + clubFeePaymentRepository.findByClubIdAndUserId(clubId, userId) + .ifPresent(p -> { + throw CustomException.of( + gg.agit.konect.global.code.ApiResponseCode.ALREADY_FEE_PAYMENT_SUBMITTED + ); + }); + + ClubFeePayment payment = ClubFeePayment.of(club, user, paymentImageUrl); + return ClubFeePaymentResponse.from(clubFeePaymentRepository.save(payment)); + } + + @Transactional + public ClubFeePaymentResponse approveFeePayment( + Integer clubId, + Integer targetUserId, + Integer requesterId + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + User approver = userRepository.getById(requesterId); + ClubFeePayment payment = clubFeePaymentRepository.getByClubIdAndUserId( + clubId, targetUserId + ); + + if (payment.isPaid()) { + throw CustomException.of( + gg.agit.konect.global.code.ApiResponseCode.ALREADY_FEE_PAYMENT_APPROVED + ); + } + + payment.approve(approver); + applicationEventPublisher.publishEvent(ClubFeePaymentApprovedEvent.of(clubId)); + + return ClubFeePaymentResponse.from(payment); + } + + public List getFeePayments(Integer clubId, Integer requesterId) { + clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + + return clubFeePaymentRepository.findAllByClubId(clubId).stream() + .map(ClubFeePaymentResponse::from) + .toList(); + } + + public ClubFeePaymentResponse getMyFeePayment(Integer clubId, Integer userId) { + clubRepository.getById(clubId); + ClubFeePayment payment = clubFeePaymentRepository.findByClubIdAndUserId(clubId, userId) + .orElseThrow(() -> CustomException.of(NOT_FOUND_FEE_PAYMENT)); + return ClubFeePaymentResponse.from(payment); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index a7a1f071..6abffa02 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -1,28 +1,18 @@ package gg.agit.konect.domain.club.service; -import static gg.agit.konect.global.code.ApiResponseCode.FAILED_SYNC_GOOGLE_SHEET; import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; -import java.io.IOException; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.List; - import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import com.google.api.services.sheets.v4.Sheets; -import com.google.api.services.sheets.v4.model.ClearValuesRequest; -import com.google.api.services.sheets.v4.model.ValueRange; - import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; @@ -35,38 +25,20 @@ @Transactional(readOnly = true) public class ClubMemberSheetService { - private static final String SHEET_RANGE = "A1"; - private static final String CLEAR_RANGE = "A:F"; - private static final DateTimeFormatter DATE_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd"); - - private static final List HEADER_ROW = - List.of("Name", "StudentId", "Email", "Phone", "Position", "JoinedAt"); - - private final Sheets googleSheetsService; private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; private final ClubPermissionValidator clubPermissionValidator; + private final SheetSyncDebouncer sheetSyncDebouncer; + private final SheetSyncExecutor sheetSyncExecutor; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onClubMemberChanged(ClubMemberChangedEvent event) { - Club club = clubRepository.getById(event.clubId()); - String spreadsheetId = club.getGoogleSheetId(); - if (spreadsheetId == null || spreadsheetId.isBlank()) { - return; - } - - List members = clubMemberRepository.findAllByClubId(event.clubId()); + sheetSyncDebouncer.debounce(event.clubId()); + } - try { - clearSheet(spreadsheetId); - writeSheet(spreadsheetId, buildRows(members)); - } catch (IOException e) { - log.error( - "Auto sheet sync failed. clubId={}, spreadsheetId={}, cause={}", - event.clubId(), spreadsheetId, e.getMessage(), e - ); - } + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onClubFeePaymentApproved(ClubFeePaymentApprovedEvent event) { + sheetSyncDebouncer.debounce(event.clubId()); } @Transactional @@ -82,7 +54,9 @@ public void updateSheetId( public ClubMemberSheetSyncResponse syncMembersToSheet( Integer clubId, - Integer requesterId + Integer requesterId, + ClubSheetSortKey sortKey, + boolean ascending ) { Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); @@ -92,52 +66,9 @@ public ClubMemberSheetSyncResponse syncMembersToSheet( throw CustomException.of(NOT_FOUND_CLUB_SHEET_ID); } - List members = clubMemberRepository.findAllByClubId(clubId); - - try { - clearSheet(spreadsheetId); - writeSheet(spreadsheetId, buildRows(members)); - } catch (IOException e) { - log.error( - "Google Sheets sync failed. spreadsheetId={}, cause={}", - spreadsheetId, e.getMessage(), e - ); - throw CustomException.of(FAILED_SYNC_GOOGLE_SHEET); - } - - return ClubMemberSheetSyncResponse.of(members.size(), spreadsheetId); - } - - private void clearSheet(String spreadsheetId) throws IOException { - googleSheetsService.spreadsheets().values() - .clear(spreadsheetId, CLEAR_RANGE, new ClearValuesRequest()) - .execute(); - } - - private void writeSheet(String spreadsheetId, List> rows) throws IOException { - ValueRange body = new ValueRange().setValues(rows); - googleSheetsService.spreadsheets().values() - .update(spreadsheetId, SHEET_RANGE, body) - .setValueInputOption("USER_ENTERED") - .execute(); - } - - private List> buildRows(List members) { - List> rows = new ArrayList<>(); - rows.add(HEADER_ROW); - - for (ClubMember member : members) { - rows.add(List.of( - member.getUser().getName(), - member.getUser().getStudentNumber(), - member.getUser().getEmail(), - member.getUser().getPhoneNumber() != null - ? "'" + member.getUser().getPhoneNumber() : "", - member.getClubPosition().getDescription(), - member.getCreatedAt().format(DATE_FORMATTER) - )); - } + long memberCount = clubMemberRepository.countByClubId(clubId); + sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); - return rows; + return ClubMemberSheetSyncResponse.of((int) memberCount, spreadsheetId); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java new file mode 100644 index 00000000..e954c04a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java @@ -0,0 +1,42 @@ +package gg.agit.konect.domain.club.service; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncDebouncer { + + private static final long DEBOUNCE_DELAY_SECONDS = 3; + + private final ConcurrentHashMap> pendingTasks = + new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = + Executors.newSingleThreadScheduledExecutor(); + + private final SheetSyncExecutor sheetSyncExecutor; + + public void debounce(Integer clubId) { + ScheduledFuture existing = pendingTasks.get(clubId); + if (existing != null && !existing.isDone()) { + existing.cancel(false); + log.debug("Sheet sync debounced. clubId={}", clubId); + } + + ScheduledFuture future = scheduler.schedule(() -> { + pendingTasks.remove(clubId); + sheetSyncExecutor.execute(clubId); + }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); + + pendingTasks.put(clubId, future); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java new file mode 100644 index 00000000..fe2c6d2c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -0,0 +1,183 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.BasicFilter; +import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.GridProperties; +import com.google.api.services.sheets.v4.model.GridRange; +import com.google.api.services.sheets.v4.model.Request; +import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; +import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubFeePayment; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetSyncExecutor { + + private static final String SHEET_RANGE = "A1"; + private static final String CLEAR_RANGE = "A:H"; + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final List HEADER_ROW = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt", "FeePaid", "PaidAt" + ); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubFeePaymentRepository clubFeePaymentRepository; + + @Async("sheetSyncExecutor") + @Transactional(readOnly = true) + public void execute(Integer clubId) { + executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + } + + @Async("sheetSyncExecutor") + @Transactional(readOnly = true) + public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { + Club club = clubRepository.getById(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + List members = clubMemberRepository.findAllByClubId(clubId); + List payments = clubFeePaymentRepository.findAllByClubId(clubId); + + Map paymentMap = payments.stream() + .collect(Collectors.toMap(p -> p.getUser().getId(), p -> p)); + + List sorted = sort(members, paymentMap, sortKey, ascending); + + try { + clearSheet(spreadsheetId); + writeSheet(spreadsheetId, buildRows(sorted, paymentMap)); + applyFormat(spreadsheetId); + } catch (IOException e) { + log.error( + "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", + clubId, spreadsheetId, e.getMessage(), e + ); + } + + log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + } + + private List sort( + List members, + Map paymentMap, + ClubSheetSortKey sortKey, + boolean ascending + ) { + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(m -> m.getUser().getName()); + case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); + case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); + case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); + case FEE_PAID -> Comparator.comparing(m -> { + ClubFeePayment p = paymentMap.get(m.getUser().getId()); + return p != null && p.isPaid() ? 0 : 1; + }); + }; + + if (!ascending) { + comparator = comparator.reversed(); + } + + return members.stream().sorted(comparator).toList(); + } + + private void clearSheet(String spreadsheetId) throws IOException { + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, CLEAR_RANGE, new ClearValuesRequest()) + .execute(); + } + + private void writeSheet(String spreadsheetId, List> rows) throws IOException { + ValueRange body = new ValueRange().setValues(rows); + googleSheetsService.spreadsheets().values() + .update(spreadsheetId, SHEET_RANGE, body) + .setValueInputOption("USER_ENTERED") + .execute(); + } + + private void applyFormat(String spreadsheetId) throws IOException { + List requests = new ArrayList<>(); + + requests.add(new Request().setUpdateSheetProperties( + new UpdateSheetPropertiesRequest() + .setProperties(new SheetProperties() + .setGridProperties(new GridProperties().setFrozenRowCount(1))) + .setFields("gridProperties.frozenRowCount") + )); + + requests.add(new Request().setSetBasicFilter( + new SetBasicFilterRequest() + .setFilter(new BasicFilter() + .setRange(new GridRange().setSheetId(0))) + )); + + googleSheetsService.spreadsheets() + .batchUpdate(spreadsheetId, new BatchUpdateSpreadsheetRequest().setRequests(requests)) + .execute(); + } + + private List> buildRows( + List members, + Map paymentMap + ) { + List> rows = new ArrayList<>(); + rows.add(HEADER_ROW); + + for (ClubMember member : members) { + Integer userId = member.getUser().getId(); + ClubFeePayment payment = paymentMap.get(userId); + + String feePaid = payment != null && payment.isPaid() ? "Y" : "N"; + String paidAt = (payment != null && payment.getApprovedAt() != null) + ? payment.getApprovedAt().format(DATE_FORMATTER) : ""; + String phone = member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""; + + rows.add(List.of( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone, + member.getClubPosition().getDescription(), + member.getCreatedAt().format(DATE_FORMATTER), + feePaid, + paidAt + )); + } + + return rows; + } +} 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 33359c58..5ad1ca83 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -92,6 +92,9 @@ public enum ApiResponseCode { NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), + NOT_FOUND_FEE_PAYMENT(HttpStatus.NOT_FOUND, "회비 납부 내역을 찾을 수 없습니다."), + ALREADY_FEE_PAYMENT_SUBMITTED(HttpStatus.CONFLICT, "이미 회비 납부 내역이 접수되었습니다."), + ALREADY_FEE_PAYMENT_APPROVED(HttpStatus.CONFLICT, "이미 승인된 회비 납부 내역입니다."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java new file mode 100644 index 00000000..b80e6ec5 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -0,0 +1,22 @@ +package gg.agit.konect.global.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + + @Bean(name = "sheetSyncExecutor") + public Executor sheetSyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("sheet-sync-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql b/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql new file mode 100644 index 00000000..08420496 --- /dev/null +++ b/src/main/resources/db/migration/V51__add_club_fee_payment_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE club_fee_payment ( + id INT NOT NULL AUTO_INCREMENT, + club_id INT NOT NULL, + user_id INT NOT NULL, + is_paid TINYINT(1) NOT NULL DEFAULT 0, + payment_image_url VARCHAR(255), + approved_at TIMESTAMP NULL, + approved_by INT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + CONSTRAINT fk_fee_payment_club FOREIGN KEY (club_id) REFERENCES club (id), + CONSTRAINT fk_fee_payment_user FOREIGN KEY (user_id) REFERENCES users (id), + CONSTRAINT fk_fee_payment_approved_by FOREIGN KEY (approved_by) REFERENCES users (id), + UNIQUE KEY uq_fee_payment_club_user (club_id, user_id) +); From 60a90e9cc99102790fe7c799f357c2255528b4d4 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 10:06:10 +0900 Subject: [PATCH 09/33] =?UTF-8?q?fix:=20Checkstyle=20-=20MagicNumber=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=B9=98=ED=99=98=20=EB=B0=8F=20NoWhitesp?= =?UTF-8?q?aceAfter=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/service/ClubMemberSheetService.java | 2 +- .../java/gg/agit/konect/global/config/AsyncConfig.java | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 6abffa02..758286fc 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -69,6 +69,6 @@ public ClubMemberSheetSyncResponse syncMembersToSheet( long memberCount = clubMemberRepository.countByClubId(clubId); sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); - return ClubMemberSheetSyncResponse.of((int) memberCount, spreadsheetId); + return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); } } diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index b80e6ec5..ef6d0eb1 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -9,12 +9,16 @@ @Configuration public class AsyncConfig { + private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; + private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; + private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; + @Bean(name = "sheetSyncExecutor") public Executor sheetSyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(2); - executor.setMaxPoolSize(4); - executor.setQueueCapacity(50); + executor.setCorePoolSize(SHEET_SYNC_CORE_POOL_SIZE); + executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); + executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); executor.setThreadNamePrefix("sheet-sync-"); executor.initialize(); return executor; From 5b787231107de336f91a1e899e51ffb6fc99977c Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 10:17:19 +0900 Subject: [PATCH 10/33] =?UTF-8?q?feat:=20Claude=20API=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=8B=9C=ED=8A=B8=20=ED=97=A4=EB=8D=94=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=EB=B0=8F=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/model/Club.java | 7 + .../domain/club/model/SheetColumnMapping.java | 47 ++++ .../club/service/ClubMemberSheetService.java | 13 ++ .../club/service/SheetHeaderMapper.java | 161 ++++++++++++++ .../club/service/SheetSyncExecutor.java | 209 +++++++++++++----- .../V52__add_sheet_column_mapping_to_club.sql | 2 + 6 files changed, 387 insertions(+), 52 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java create mode 100644 src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 14762f86..e7dcec77 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -87,6 +87,9 @@ public class Club extends BaseEntity { @Column(name = "google_sheet_id", length = 255) private String googleSheetId; + @Column(name = "sheet_column_mapping", columnDefinition = "JSON") + private String sheetColumnMapping; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -231,4 +234,8 @@ private void clearFeeInfo() { public void updateGoogleSheetId(String googleSheetId) { this.googleSheetId = googleSheetId; } + + public void updateSheetColumnMapping(String sheetColumnMapping) { + this.sheetColumnMapping = sheetColumnMapping; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java new file mode 100644 index 00000000..3cf32f07 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -0,0 +1,47 @@ +package gg.agit.konect.domain.club.model; + +import java.util.HashMap; +import java.util.Map; + +public class SheetColumnMapping { + + public static final String NAME = "name"; + public static final String STUDENT_ID = "studentId"; + public static final String EMAIL = "email"; + public static final String PHONE = "phone"; + public static final String POSITION = "position"; + public static final String JOINED_AT = "joinedAt"; + public static final String FEE_PAID = "feePaid"; + public static final String PAID_AT = "paidAt"; + + private final Map fieldToColumn; + + public SheetColumnMapping(Map fieldToColumn) { + this.fieldToColumn = new HashMap<>(fieldToColumn); + } + + public static SheetColumnMapping defaultMapping() { + Map mapping = new HashMap<>(); + mapping.put(NAME, 0); + mapping.put(STUDENT_ID, 1); + mapping.put(EMAIL, 2); + mapping.put(PHONE, 3); + mapping.put(POSITION, 4); + mapping.put(JOINED_AT, 5); + mapping.put(FEE_PAID, 6); + mapping.put(PAID_AT, 7); + return new SheetColumnMapping(mapping); + } + + public boolean hasColumn(String field) { + return fieldToColumn.containsKey(field); + } + + public int getColumnIndex(String field) { + return fieldToColumn.getOrDefault(field, -1); + } + + public Map toMap() { + return new HashMap<>(fieldToColumn); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 758286fc..ac28d47e 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -7,12 +7,16 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; @@ -30,6 +34,8 @@ public class ClubMemberSheetService { private final ClubPermissionValidator clubPermissionValidator; private final SheetSyncDebouncer sheetSyncDebouncer; private final SheetSyncExecutor sheetSyncExecutor; + private final SheetHeaderMapper sheetHeaderMapper; + private final ObjectMapper objectMapper; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onClubMemberChanged(ClubMemberChangedEvent event) { @@ -50,6 +56,13 @@ public void updateSheetId( Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); club.updateGoogleSheetId(request.spreadsheetId()); + + SheetColumnMapping mapping = sheetHeaderMapper.analyzeHeaders(request.spreadsheetId()); + try { + club.updateSheetColumnMapping(objectMapper.writeValueAsString(mapping.toMap())); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); + } } public ClubMemberSheetSyncResponse syncMembersToSheet( diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java new file mode 100644 index 00000000..d15d9ef4 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -0,0 +1,161 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SheetHeaderMapper { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; + private static final int MAX_TOKENS = 256; + private static final String HEADER_RANGE = "1:1"; + + private final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + private final RestClient restClient; + private final ObjectMapper objectMapper; + + public SheetColumnMapping analyzeHeaders(String spreadsheetId) { + List headers = readHeaders(spreadsheetId); + if (headers.isEmpty()) { + log.warn("No headers found in spreadsheet. Using default mapping."); + return SheetColumnMapping.defaultMapping(); + } + + try { + return inferMapping(headers); + } catch (Exception e) { + log.warn( + "Header analysis failed, using default mapping. cause={}", + e.getMessage() + ); + return SheetColumnMapping.defaultMapping(); + } + } + + private List readHeaders(String spreadsheetId) { + try { + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, HEADER_RANGE) + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + return values.get(0).stream() + .map(Object::toString) + .toList(); + + } catch (IOException e) { + log.error("Failed to read headers. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private SheetColumnMapping inferMapping(List headers) throws Exception { + String prompt = buildPrompt(headers); + String rawJson = callClaude(prompt); + return parseMapping(rawJson, headers.size()); + } + + private String buildPrompt(List headers) { + return String.format(""" + The following are column headers from a spreadsheet used by a Korean university club: + %s + + Map each header to one of these field names if it matches. Column index starts at 0. + Fields: name, studentId, email, phone, position, joinedAt, feePaid, paidAt + + Rules: + - "name" = member's name (이름, 성명, 이름 등) + - "studentId" = student number (학번, 학생번호 등) + - "email" = email address (이메일, 이메일주소 등) + - "phone" = phone number (전화번호, 연락처, 핸드폰 등) + - "position" = role/position in club (직책, 직급, 역할 등) + - "joinedAt" = join date (가입일, 가입날짜, 입부일 등) + - "feePaid" = fee payment status (회비, 납부여부, 납부, 회비납부 등) + - "paidAt" = fee payment date (납부일, 납부날짜 등) + + Respond ONLY with a JSON object like: + {"name": 0, "studentId": 1, "email": 2} + Only include fields you are confident about. Do not include explanation. + """, headers); + } + + private String callClaude(String prompt) { + Map request = Map.of( + "model", MAPPING_MODEL, + "max_tokens", MAX_TOKENS, + "messages", List.of(Map.of("role", "user", "content", prompt)) + ); + + try { + String response = restClient.post() + .uri(API_URL) + .header("x-api-key", claudeProperties.apiKey()) + .header("anthropic-version", ANTHROPIC_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class); + + JsonNode root = objectMapper.readTree(response); + return root.path("content").get(0).path("text").asText(); + + } catch (RestClientException | IOException e) { + throw new RuntimeException("Claude API call failed", e); + } + } + + private SheetColumnMapping parseMapping(String rawJson, int headerCount) { + try { + String cleaned = rawJson.trim(); + int start = cleaned.indexOf('{'); + int end = cleaned.lastIndexOf('}'); + if (start < 0 || end < 0) { + throw new IllegalArgumentException("No JSON object found in response"); + } + cleaned = cleaned.substring(start, end + 1); + + JsonNode node = objectMapper.readTree(cleaned); + Map mapping = new HashMap<>(); + + node.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0 && colIndex < headerCount) { + mapping.put(entry.getKey(), colIndex); + } + }); + + log.info("Sheet header mapping resolved: {}", mapping); + return new SheetColumnMapping(mapping); + + } catch (Exception e) { + log.warn("Failed to parse mapping JSON: {}. Using default.", rawJson); + return SheetColumnMapping.defaultMapping(); + } + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index fe2c6d2c..4f8f16b5 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -4,6 +4,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -12,6 +13,8 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +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.BasicFilter; import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; @@ -21,6 +24,7 @@ import com.google.api.services.sheets.v4.model.Request; import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; import com.google.api.services.sheets.v4.model.ValueRange; @@ -28,6 +32,7 @@ import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.ClubFeePayment; import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; @@ -40,18 +45,14 @@ public class SheetSyncExecutor { private static final String SHEET_RANGE = "A1"; - private static final String CLEAR_RANGE = "A:H"; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - private static final List HEADER_ROW = List.of( - "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt", "FeePaid", "PaidAt" - ); - private final Sheets googleSheetsService; private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; private final ClubFeePaymentRepository clubFeePaymentRepository; + private final ObjectMapper objectMapper; @Async("sheetSyncExecutor") @Transactional(readOnly = true) @@ -68,6 +69,7 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as return; } + SheetColumnMapping mapping = resolveMapping(club); List members = clubMemberRepository.findAllByClubId(clubId); List payments = clubFeePaymentRepository.findAllByClubId(clubId); @@ -77,9 +79,12 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as List sorted = sort(members, paymentMap, sortKey, ascending); try { - clearSheet(spreadsheetId); - writeSheet(spreadsheetId, buildRows(sorted, paymentMap)); - applyFormat(spreadsheetId); + if (club.getSheetColumnMapping() != null) { + updateMappedColumns(spreadsheetId, sorted, paymentMap, mapping); + } else { + clearAndWriteAll(spreadsheetId, sorted, paymentMap); + applyFormat(spreadsheetId); + } } catch (IOException e) { log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", @@ -90,37 +95,134 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } - private List sort( + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + return SheetColumnMapping.defaultMapping(); + } + try { + Map map = objectMapper.readValue( + mappingJson, new TypeReference<>() {} + ); + return new SheetColumnMapping(map); + } catch (Exception e) { + log.warn("Failed to parse sheet mapping, using default. cause={}", e.getMessage()); + return SheetColumnMapping.defaultMapping(); + } + } + + private void updateMappedColumns( + String spreadsheetId, List members, Map paymentMap, - ClubSheetSortKey sortKey, - boolean ascending + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = 2; + Map> columnData = buildColumnData(members, paymentMap, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + List values = entry.getValue(); + String colLetter = columnLetter(colIndex); + String range = colLetter + dataStartRow + ":" + colLetter; + List> wrapped = values.stream().map(v -> List.of((Object)v)).toList(); + data.add(new ValueRange().setRange(range).setValues(wrapped)); + } + + if (!data.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchUpdate(spreadsheetId, + new BatchUpdateValuesRequest() + .setValueInputOption("USER_ENTERED") + .setData(data)) + .execute(); + } + } + + private Map> buildColumnData( + List members, + Map paymentMap, + SheetColumnMapping mapping ) { - Comparator comparator = switch (sortKey) { - case NAME -> Comparator.comparing(m -> m.getUser().getName()); - case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); - case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); - case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); - case FEE_PAID -> Comparator.comparing(m -> { - ClubFeePayment p = paymentMap.get(m.getUser().getId()); - return p != null && p.isPaid() ? 0 : 1; - }); - }; + Map> columns = new HashMap<>(); - if (!ascending) { - comparator = comparator.reversed(); + for (ClubMember member : members) { + Integer userId = member.getUser().getId(); + ClubFeePayment payment = paymentMap.get(userId); + + putValue(columns, mapping, SheetColumnMapping.NAME, + member.getUser().getName()); + putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, + member.getUser().getStudentNumber()); + putValue(columns, mapping, SheetColumnMapping.EMAIL, + member.getUser().getEmail()); + putValue(columns, mapping, SheetColumnMapping.PHONE, + member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""); + putValue(columns, mapping, SheetColumnMapping.POSITION, + member.getClubPosition().getDescription()); + putValue(columns, mapping, SheetColumnMapping.JOINED_AT, + member.getCreatedAt().format(DATE_FORMATTER)); + putValue(columns, mapping, SheetColumnMapping.FEE_PAID, + payment != null && payment.isPaid() ? "Y" : "N"); + putValue(columns, mapping, SheetColumnMapping.PAID_AT, + payment != null && payment.getApprovedAt() != null + ? payment.getApprovedAt().format(DATE_FORMATTER) : ""); } - return members.stream().sorted(comparator).toList(); + return columns; + } + + private void putValue( + Map> columns, + SheetColumnMapping mapping, + String field, + Object value + ) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + columns.computeIfAbsent(colIndex, k -> new ArrayList<>()).add(value); + } } - private void clearSheet(String spreadsheetId) throws IOException { + private void clearAndWriteAll( + String spreadsheetId, + List members, + Map paymentMap + ) throws IOException { + String clearRange = "A:H"; googleSheetsService.spreadsheets().values() - .clear(spreadsheetId, CLEAR_RANGE, new ClearValuesRequest()) + .clear(spreadsheetId, clearRange, new ClearValuesRequest()) .execute(); - } - private void writeSheet(String spreadsheetId, List> rows) throws IOException { + List headerRow = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt", "FeePaid", "PaidAt" + ); + List> rows = new ArrayList<>(); + rows.add(headerRow); + + for (ClubMember member : members) { + Integer userId = member.getUser().getId(); + ClubFeePayment payment = paymentMap.get(userId); + String phone = member.getUser().getPhoneNumber() != null + ? "'" + member.getUser().getPhoneNumber() : ""; + String feePaid = payment != null && payment.isPaid() ? "Y" : "N"; + String paidAt = payment != null && payment.getApprovedAt() != null + ? payment.getApprovedAt().format(DATE_FORMATTER) : ""; + + rows.add(List.of( + member.getUser().getName(), + member.getUser().getStudentNumber(), + member.getUser().getEmail(), + phone, + member.getClubPosition().getDescription(), + member.getCreatedAt().format(DATE_FORMATTER), + feePaid, + paidAt + )); + } + ValueRange body = new ValueRange().setValues(rows); googleSheetsService.spreadsheets().values() .update(spreadsheetId, SHEET_RANGE, body) @@ -149,35 +251,38 @@ private void applyFormat(String spreadsheetId) throws IOException { .execute(); } - private List> buildRows( + private List sort( List members, - Map paymentMap + Map paymentMap, + ClubSheetSortKey sortKey, + boolean ascending ) { - List> rows = new ArrayList<>(); - rows.add(HEADER_ROW); + Comparator comparator = switch (sortKey) { + case NAME -> Comparator.comparing(m -> m.getUser().getName()); + case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); + case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); + case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); + case FEE_PAID -> Comparator.comparing(m -> { + ClubFeePayment p = paymentMap.get(m.getUser().getId()); + return p != null && p.isPaid() ? 0 : 1; + }); + }; - for (ClubMember member : members) { - Integer userId = member.getUser().getId(); - ClubFeePayment payment = paymentMap.get(userId); + if (!ascending) { + comparator = comparator.reversed(); + } - String feePaid = payment != null && payment.isPaid() ? "Y" : "N"; - String paidAt = (payment != null && payment.getApprovedAt() != null) - ? payment.getApprovedAt().format(DATE_FORMATTER) : ""; - String phone = member.getUser().getPhoneNumber() != null - ? "'" + member.getUser().getPhoneNumber() : ""; + return members.stream().sorted(comparator).toList(); + } - rows.add(List.of( - member.getUser().getName(), - member.getUser().getStudentNumber(), - member.getUser().getEmail(), - phone, - member.getClubPosition().getDescription(), - member.getCreatedAt().format(DATE_FORMATTER), - feePaid, - paidAt - )); + private String columnLetter(int index) { + StringBuilder sb = new StringBuilder(); + index++; + while (index > 0) { + index--; + sb.insert(0, (char)('A' + index % 26)); + index /= 26; } - - return rows; + return sb.toString(); } } diff --git a/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql b/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql new file mode 100644 index 00000000..7e9a1be3 --- /dev/null +++ b/src/main/resources/db/migration/V52__add_sheet_column_mapping_to_club.sql @@ -0,0 +1,2 @@ +ALTER TABLE club + ADD COLUMN sheet_column_mapping JSON NULL; From 916cc619f4019f41e9c2890296ca3857d240c41d Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 10:19:03 +0900 Subject: [PATCH 11/33] =?UTF-8?q?fix:=20Checkstyle=20MagicNumber=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/model/SheetColumnMapping.java | 25 +++++++++++++------ .../club/service/SheetSyncExecutor.java | 5 ++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java index 3cf32f07..1744ef4a 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -14,6 +14,15 @@ public class SheetColumnMapping { public static final String FEE_PAID = "feePaid"; public static final String PAID_AT = "paidAt"; + private static final int COL_NAME = 0; + private static final int COL_STUDENT_ID = 1; + private static final int COL_EMAIL = 2; + private static final int COL_PHONE = 3; + private static final int COL_POSITION = 4; + private static final int COL_JOINED_AT = 5; + private static final int COL_FEE_PAID = 6; + private static final int COL_PAID_AT = 7; + private final Map fieldToColumn; public SheetColumnMapping(Map fieldToColumn) { @@ -22,14 +31,14 @@ public SheetColumnMapping(Map fieldToColumn) { public static SheetColumnMapping defaultMapping() { Map mapping = new HashMap<>(); - mapping.put(NAME, 0); - mapping.put(STUDENT_ID, 1); - mapping.put(EMAIL, 2); - mapping.put(PHONE, 3); - mapping.put(POSITION, 4); - mapping.put(JOINED_AT, 5); - mapping.put(FEE_PAID, 6); - mapping.put(PAID_AT, 7); + mapping.put(NAME, COL_NAME); + mapping.put(STUDENT_ID, COL_STUDENT_ID); + mapping.put(EMAIL, COL_EMAIL); + mapping.put(PHONE, COL_PHONE); + mapping.put(POSITION, COL_POSITION); + mapping.put(JOINED_AT, COL_JOINED_AT); + mapping.put(FEE_PAID, COL_FEE_PAID); + mapping.put(PAID_AT, COL_PAID_AT); return new SheetColumnMapping(mapping); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 4f8f16b5..ec1a7aad 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -45,6 +45,7 @@ public class SheetSyncExecutor { private static final String SHEET_RANGE = "A1"; + private static final int ALPHABET_SIZE = 26; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -280,8 +281,8 @@ private String columnLetter(int index) { index++; while (index > 0) { index--; - sb.insert(0, (char)('A' + index % 26)); - index /= 26; + sb.insert(0, (char)('A' + index % ALPHABET_SIZE)); + index /= ALPHABET_SIZE; } return sb.toString(); } From bcf5c3c4b29ea903fc343ce09bacd922dbc1f426 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 13:29:04 +0900 Subject: [PATCH 12/33] =?UTF-8?q?fix:=20sheetSyncExecutor=20Bean=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?-=20sheetSyncTaskExecutor=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/service/SheetSyncExecutor.java | 4 ++-- src/main/java/gg/agit/konect/global/config/AsyncConfig.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index ec1a7aad..85c77ac0 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -55,13 +55,13 @@ public class SheetSyncExecutor { private final ClubFeePaymentRepository clubFeePaymentRepository; private final ObjectMapper objectMapper; - @Async("sheetSyncExecutor") + @Async("sheetSyncTaskExecutor") @Transactional(readOnly = true) public void execute(Integer clubId) { executeWithSort(clubId, ClubSheetSortKey.POSITION, true); } - @Async("sheetSyncExecutor") + @Async("sheetSyncTaskExecutor") @Transactional(readOnly = true) public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { Club club = clubRepository.getById(clubId); diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index ef6d0eb1..beb94d66 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -13,8 +13,8 @@ public class AsyncConfig { private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; - @Bean(name = "sheetSyncExecutor") - public Executor sheetSyncExecutor() { + @Bean(name = "sheetSyncTaskExecutor") + public Executor sheetSyncTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(SHEET_SYNC_CORE_POOL_SIZE); executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); From ae151f31eb59e00049880df3cc8218abae06db96 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 14:04:37 +0900 Subject: [PATCH 13/33] =?UTF-8?q?fix:=20SheetHeaderMapper=20RestClient.Bui?= =?UTF-8?q?lder=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(Bean=20=EB=AF=B8=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/service/SheetHeaderMapper.java | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index d15d9ef4..166aa059 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -17,12 +17,10 @@ import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.infrastructure.claude.config.ClaudeProperties; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j @Component -@RequiredArgsConstructor public class SheetHeaderMapper { private static final String API_URL = "https://api.anthropic.com/v1/messages"; @@ -33,8 +31,20 @@ public class SheetHeaderMapper { private final Sheets googleSheetsService; private final ClaudeProperties claudeProperties; - private final RestClient restClient; private final ObjectMapper objectMapper; + private final RestClient restClient; + + public SheetHeaderMapper( + Sheets googleSheetsService, + ClaudeProperties claudeProperties, + ObjectMapper objectMapper, + RestClient.Builder restClientBuilder + ) { + this.googleSheetsService = googleSheetsService; + this.claudeProperties = claudeProperties; + this.objectMapper = objectMapper; + this.restClient = restClientBuilder.build(); + } public SheetColumnMapping analyzeHeaders(String spreadsheetId) { List headers = readHeaders(spreadsheetId); From d453f386e9ef34c731081c3820d2ed59d476e031 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 14:07:42 +0900 Subject: [PATCH 14/33] =?UTF-8?q?fix:=20@TransactionalEventListener=20+=20?= =?UTF-8?q?@Transactional=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20-=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=A0=88=EB=B2=A8=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/service/ClubMemberSheetService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index ac28d47e..c4e5d7ba 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -26,7 +26,6 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class ClubMemberSheetService { private final ClubRepository clubRepository; @@ -65,6 +64,7 @@ public void updateSheetId( } } + @Transactional(readOnly = true) public ClubMemberSheetSyncResponse syncMembersToSheet( Integer clubId, Integer requesterId, From d0ddd96cba50bebec6486f75bbdf3789fe592eda Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 15:30:26 +0900 Subject: [PATCH 15/33] =?UTF-8?q?feat:=20=EC=8B=9C=ED=8A=B8=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=9C=84=EC=B9=98=20=EC=9E=90=EB=8F=99=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80=20-=20=EC=83=81=EC=9C=84=2010=ED=96=89=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=20=EB=B0=8F=20dataStartRow=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/model/SheetColumnMapping.java | 21 +++- .../club/service/ClubMemberSheetService.java | 4 +- .../club/service/SheetHeaderMapper.java | 100 +++++++++++------- .../club/service/SheetSyncExecutor.java | 14 ++- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java index 1744ef4a..600cfde2 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -22,11 +22,18 @@ public class SheetColumnMapping { private static final int COL_JOINED_AT = 5; private static final int COL_FEE_PAID = 6; private static final int COL_PAID_AT = 7; + private static final int DEFAULT_DATA_START_ROW = 2; private final Map fieldToColumn; + private final int dataStartRow; - public SheetColumnMapping(Map fieldToColumn) { + public SheetColumnMapping(Map fieldToColumn, int dataStartRow) { this.fieldToColumn = new HashMap<>(fieldToColumn); + this.dataStartRow = dataStartRow; + } + + public SheetColumnMapping(Map fieldToColumn) { + this(fieldToColumn, DEFAULT_DATA_START_ROW); } public static SheetColumnMapping defaultMapping() { @@ -39,7 +46,7 @@ public static SheetColumnMapping defaultMapping() { mapping.put(JOINED_AT, COL_JOINED_AT); mapping.put(FEE_PAID, COL_FEE_PAID); mapping.put(PAID_AT, COL_PAID_AT); - return new SheetColumnMapping(mapping); + return new SheetColumnMapping(mapping, DEFAULT_DATA_START_ROW); } public boolean hasColumn(String field) { @@ -50,7 +57,13 @@ public int getColumnIndex(String field) { return fieldToColumn.getOrDefault(field, -1); } - public Map toMap() { - return new HashMap<>(fieldToColumn); + public int getDataStartRow() { + return dataStartRow; + } + + public Map toMap() { + Map result = new HashMap<>(fieldToColumn); + result.put("dataStartRow", dataStartRow); + return result; } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index c4e5d7ba..17b91439 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -58,7 +58,9 @@ public void updateSheetId( SheetColumnMapping mapping = sheetHeaderMapper.analyzeHeaders(request.spreadsheetId()); try { - club.updateSheetColumnMapping(objectMapper.writeValueAsString(mapping.toMap())); + club.updateSheetColumnMapping( + objectMapper.writeValueAsString(mapping.toMap()) + ); } catch (JsonProcessingException e) { log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 166aa059..509296ff 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -1,6 +1,7 @@ package gg.agit.konect.domain.club.service; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -26,8 +27,9 @@ public class SheetHeaderMapper { private static final String API_URL = "https://api.anthropic.com/v1/messages"; private static final String ANTHROPIC_VERSION = "2023-06-01"; private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; - private static final int MAX_TOKENS = 256; - private static final String HEADER_RANGE = "1:1"; + private static final int MAX_TOKENS = 512; + private static final int SCAN_ROWS = 10; + private static final String SCAN_RANGE = "A1:Z10"; private final Sheets googleSheetsService; private final ClaudeProperties claudeProperties; @@ -47,14 +49,14 @@ public SheetHeaderMapper( } public SheetColumnMapping analyzeHeaders(String spreadsheetId) { - List headers = readHeaders(spreadsheetId); - if (headers.isEmpty()) { - log.warn("No headers found in spreadsheet. Using default mapping."); + List> rows = readRows(spreadsheetId); + if (rows.isEmpty()) { + log.warn("No data found in spreadsheet. Using default mapping."); return SheetColumnMapping.defaultMapping(); } try { - return inferMapping(headers); + return inferMapping(rows); } catch (Exception e) { log.warn( "Header analysis failed, using default mapping. cause={}", @@ -64,10 +66,10 @@ public SheetColumnMapping analyzeHeaders(String spreadsheetId) { } } - private List readHeaders(String spreadsheetId) { + private List> readRows(String spreadsheetId) { try { ValueRange response = googleSheetsService.spreadsheets().values() - .get(spreadsheetId, HEADER_RANGE) + .get(spreadsheetId, SCAN_RANGE) .execute(); List> values = response.getValues(); @@ -75,44 +77,59 @@ private List readHeaders(String spreadsheetId) { return List.of(); } - return values.get(0).stream() - .map(Object::toString) - .toList(); + List> rows = new ArrayList<>(); + int limit = Math.min(values.size(), SCAN_ROWS); + for (int i = 0; i < limit; i++) { + List row = values.get(i).stream() + .map(Object::toString) + .toList(); + rows.add(row); + } + return rows; } catch (IOException e) { - log.error("Failed to read headers. spreadsheetId={}", spreadsheetId, e); + log.error("Failed to read rows. spreadsheetId={}", spreadsheetId, e); return List.of(); } } - private SheetColumnMapping inferMapping(List headers) throws Exception { - String prompt = buildPrompt(headers); + private SheetColumnMapping inferMapping(List> rows) throws Exception { + String prompt = buildPrompt(rows); String rawJson = callClaude(prompt); - return parseMapping(rawJson, headers.size()); + return parseMapping(rawJson); } - private String buildPrompt(List headers) { + private String buildPrompt(List> rows) { + StringBuilder rowsDescription = new StringBuilder(); + for (int i = 0; i < rows.size(); i++) { + rowsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + } + return String.format(""" - The following are column headers from a spreadsheet used by a Korean university club: - %s + Below are the first rows of a spreadsheet used by a Korean university club: - Map each header to one of these field names if it matches. Column index starts at 0. + %s + First, identify which row contains the column headers (not title or blank rows). + Then, map each header to one of these field names. Column index starts at 0. Fields: name, studentId, email, phone, position, joinedAt, feePaid, paidAt Rules: - - "name" = member's name (이름, 성명, 이름 등) - - "studentId" = student number (학번, 학생번호 등) - - "email" = email address (이메일, 이메일주소 등) - - "phone" = phone number (전화번호, 연락처, 핸드폰 등) - - "position" = role/position in club (직책, 직급, 역할 등) - - "joinedAt" = join date (가입일, 가입날짜, 입부일 등) - - "feePaid" = fee payment status (회비, 납부여부, 납부, 회비납부 등) - - "paidAt" = fee payment date (납부일, 납부날짜 등) - - Respond ONLY with a JSON object like: - {"name": 0, "studentId": 1, "email": 2} - Only include fields you are confident about. Do not include explanation. - """, headers); + - "name" = member's name (이름, 성명 etc.) + - "studentId" = student number (학번, 학생번호 etc.) + - "email" = email address (이메일, 이메일주소 etc.) + - "phone" = phone number (전화번호, 연락처, 핸드폰 etc.) + - "position" = role in club (직책, 직급, 역할 etc.) + - "joinedAt" = join date (가입일, 가입날짜, 입부일 etc.) + - "feePaid" = fee payment status (회비, 납부여부, 회비납부 etc.) + - "paidAt" = fee payment date (납부일, 납부날짜 etc.) + + Respond ONLY with a JSON object in this exact format: + {"headerRow": 1, "mapping": {"name": 0, "studentId": 1}} + + - "headerRow" is the 1-indexed row number of the header row. + - "mapping" contains only fields you are confident about. + - Do not include any explanation. + """, rowsDescription); } private String callClaude(String prompt) { @@ -140,7 +157,7 @@ private String callClaude(String prompt) { } } - private SheetColumnMapping parseMapping(String rawJson, int headerCount) { + private SheetColumnMapping parseMapping(String rawJson) { try { String cleaned = rawJson.trim(); int start = cleaned.indexOf('{'); @@ -150,18 +167,25 @@ private SheetColumnMapping parseMapping(String rawJson, int headerCount) { } cleaned = cleaned.substring(start, end + 1); - JsonNode node = objectMapper.readTree(cleaned); + JsonNode root = objectMapper.readTree(cleaned); + int headerRow = root.path("headerRow").asInt(1); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = root.path("mapping"); Map mapping = new HashMap<>(); - node.fields().forEachRemaining(entry -> { + mappingNode.fields().forEachRemaining(entry -> { int colIndex = entry.getValue().asInt(-1); - if (colIndex >= 0 && colIndex < headerCount) { + if (colIndex >= 0) { mapping.put(entry.getKey(), colIndex); } }); - log.info("Sheet header mapping resolved: {}", mapping); - return new SheetColumnMapping(mapping); + log.info( + "Sheet header mapping resolved. headerRow={}, dataStartRow={}, mapping={}", + headerRow, dataStartRow, mapping + ); + return new SheetColumnMapping(mapping, dataStartRow); } catch (Exception e) { log.warn("Failed to parse mapping JSON: {}. Using default.", rawJson); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 85c77ac0..8b1be3c0 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -102,10 +102,18 @@ private SheetColumnMapping resolveMapping(Club club) { return SheetColumnMapping.defaultMapping(); } try { - Map map = objectMapper.readValue( + Map raw = objectMapper.readValue( mappingJson, new TypeReference<>() {} ); - return new SheetColumnMapping(map); + 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 num) { + fieldMap.put(key, num.intValue()); + } + }); + return new SheetColumnMapping(fieldMap, dataStartRow); } catch (Exception e) { log.warn("Failed to parse sheet mapping, using default. cause={}", e.getMessage()); return SheetColumnMapping.defaultMapping(); @@ -118,7 +126,7 @@ private void updateMappedColumns( Map paymentMap, SheetColumnMapping mapping ) throws IOException { - int dataStartRow = 2; + int dataStartRow = mapping.getDataStartRow(); Map> columnData = buildColumnData(members, paymentMap, mapping); List data = new ArrayList<>(); From 23fc56bab9f57d7b303d7266f929cca81352622c Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 15:31:45 +0900 Subject: [PATCH 16/33] =?UTF-8?q?fix:=20Checkstyle=20NoWhitespaceAfter=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/service/SheetSyncExecutor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 8b1be3c0..eb9f392a 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -106,7 +106,7 @@ private SheetColumnMapping resolveMapping(Club club) { mappingJson, new TypeReference<>() {} ); int dataStartRow = raw.containsKey("dataStartRow") - ? ((Number) raw.get("dataStartRow")).intValue() : 2; + ? ((Number)raw.get("dataStartRow")).intValue() : 2; Map fieldMap = new HashMap<>(); raw.forEach((key, value) -> { if (!"dataStartRow".equals(key) && value instanceof Number num) { From cef50335413f326573baf188ed58dc2e450400e4 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 21:09:06 +0900 Subject: [PATCH 17/33] =?UTF-8?q?docs:=20ClubMemberSheetApi=20Swagger=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubMemberSheetApi.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index a33819e6..f8e0463c 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -20,7 +20,13 @@ @RequestMapping("/clubs") public interface ClubMemberSheetApi { - @Operation(summary = "Register or update the Google Spreadsheet ID for a club.") + @Operation( + summary = "구글 스프레드시트 ID 등록 / 수정", + description = "동아리에서 사용 중인 구글 스프레드시트 ID를 등록하거나 수정합니다. " + + "등록 시 AI(Claude Haiku)가 시트 상단 10행을 자동으로 분석하여 " + + "이름·학번·연락처 등 컬럼 위치를 파악하고, 이후 동기화 시 해당 컬럼에만 값을 채웁니다. " + + "시트 양식이 변경된 경우 이 API를 다시 호출하면 AI가 재분석합니다." + ) @PutMapping("/{clubId}/sheet") ResponseEntity updateSheetId( @PathVariable(name = "clubId") Integer clubId, @@ -28,7 +34,13 @@ ResponseEntity updateSheetId( @UserId Integer requesterId ); - @Operation(summary = "Export club member list to the registered Google Spreadsheet.") + @Operation( + summary = "동아리 인명부 스프레드시트 동기화", + description = "등록된 구글 스프레드시트에 동아리 회원 인명부와 회비 납부 현황을 동기화합니다. " + + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT, FEE_PAID)을 지정할 수 있으며, " + + "ascending으로 오름차순/내림차순을 설정합니다. " + + "가입 승인·탈퇴·회비 납부 승인 시에도 자동으로 동기화됩니다." + ) @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( @PathVariable(name = "clubId") Integer clubId, From a0477d8de3f22416d9806be3120b2da1b0d5bd09 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 21:50:49 +0900 Subject: [PATCH 18/33] =?UTF-8?q?docs:=20ClubFeePaymentApi=20Swagger=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=ED=95=9C=EA=B8=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubFeePaymentApi.java | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java index c3a54fa6..79a11181 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java @@ -19,7 +19,10 @@ @RequestMapping("/clubs") public interface ClubFeePaymentApi { - @Operation(summary = "Submit club fee payment.") + @Operation( + summary = "회비 납부 접수", + description = "회원이 회비를 납부했음을 접수합니다. 납부 증빙 이미지 URL을 함께 제출할 수 있습니다." + ) @PostMapping("/{clubId}/fee-payments") ResponseEntity submitFeePayment( @PathVariable(name = "clubId") Integer clubId, @@ -27,7 +30,11 @@ ResponseEntity submitFeePayment( @UserId Integer requesterId ); - @Operation(summary = "Approve a member's fee payment. Manager only.") + @Operation( + summary = "회비 납부 승인 (운영진 전용)", + description = "운영진이 특정 회원의 회비 납부를 승인합니다. " + + "승인 즉시 구글 스프레드시트의 해당 회원 납부 여부(FeePaid) 컬럼이 자동으로 업데이트됩니다." + ) @PostMapping("/{clubId}/fee-payments/{targetUserId}/approve") ResponseEntity approveFeePayment( @PathVariable(name = "clubId") Integer clubId, @@ -35,14 +42,20 @@ ResponseEntity approveFeePayment( @UserId Integer requesterId ); - @Operation(summary = "Get all fee payments for a club. Manager only.") + @Operation( + summary = "전체 회비 납부 목록 조회 (운영진 전용)", + description = "동아리 전체 회원의 회비 납부 현황을 조회합니다. 납부 여부, 납부일, 증빙 이미지 URL을 확인할 수 있습니다." + ) @GetMapping("/{clubId}/fee-payments") ResponseEntity> getFeePayments( @PathVariable(name = "clubId") Integer clubId, @UserId Integer requesterId ); - @Operation(summary = "Get my fee payment status.") + @Operation( + summary = "내 회비 납부 상태 조회", + description = "로그인한 회원 본인의 회비 납부 상태를 조회합니다." + ) @GetMapping("/{clubId}/fee-payments/me") ResponseEntity getMyFeePayment( @PathVariable(name = "clubId") Integer clubId, From 62b973fbfce7a13b5855f47f6781faf1c73aa433 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 10:25:49 +0900 Subject: [PATCH 19/33] =?UTF-8?q?feat:=20=ED=9A=8C=EB=B9=84=20=EC=9E=A5?= =?UTF-8?q?=EB=B6=80=20=EB=B3=84=EB=8F=84=20=ED=83=AD=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EA=B0=90=EC=A7=80=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20-=20AI=EA=B0=80=20=EC=9D=B8=EB=AA=85?= =?UTF-8?q?=EB=B6=80/=ED=9A=8C=EB=B9=84=20=EC=9E=A5=EB=B6=80=20=ED=83=AD?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/model/Club.java | 11 + .../club/service/ClubMemberSheetService.java | 11 +- .../club/service/SheetHeaderMapper.java | 203 ++++++++++++------ .../club/service/SheetSyncExecutor.java | 67 +++++- .../V53__add_fee_sheet_columns_to_club.sql | 3 + 5 files changed, 226 insertions(+), 69 deletions(-) create mode 100644 src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index e7dcec77..c69c65bb 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -90,6 +90,12 @@ public class Club extends BaseEntity { @Column(name = "sheet_column_mapping", columnDefinition = "JSON") private String sheetColumnMapping; + @Column(name = "fee_sheet_id") + private Integer feeSheetId; + + @Column(name = "fee_sheet_column_mapping", columnDefinition = "JSON") + private String feeSheetColumnMapping; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -238,4 +244,9 @@ public void updateGoogleSheetId(String googleSheetId) { public void updateSheetColumnMapping(String sheetColumnMapping) { this.sheetColumnMapping = sheetColumnMapping; } + + public void updateFeeSheet(Integer feeSheetId, String feeSheetColumnMapping) { + this.feeSheetId = feeSheetId; + this.feeSheetColumnMapping = feeSheetColumnMapping; + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 17b91439..a7c81d6c 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -56,11 +56,18 @@ public void updateSheetId( clubPermissionValidator.validateManagerAccess(clubId, requesterId); club.updateGoogleSheetId(request.spreadsheetId()); - SheetColumnMapping mapping = sheetHeaderMapper.analyzeHeaders(request.spreadsheetId()); + SheetHeaderMapper.SheetAnalysisResult result = + sheetHeaderMapper.analyzeAllSheets(request.spreadsheetId()); try { club.updateSheetColumnMapping( - objectMapper.writeValueAsString(mapping.toMap()) + objectMapper.writeValueAsString(result.memberListMapping().toMap()) ); + if (result.feeSheetId() != null && result.feeLedgerMapping() != null) { + club.updateFeeSheet( + result.feeSheetId(), + objectMapper.writeValueAsString(result.feeLedgerMapping().toMap()) + ); + } } catch (JsonProcessingException e) { log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 509296ff..f6a429b3 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -14,6 +14,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.Sheet; +import com.google.api.services.sheets.v4.model.Spreadsheet; import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.model.SheetColumnMapping; @@ -27,9 +29,8 @@ public class SheetHeaderMapper { private static final String API_URL = "https://api.anthropic.com/v1/messages"; private static final String ANTHROPIC_VERSION = "2023-06-01"; private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; - private static final int MAX_TOKENS = 512; + private static final int MAX_TOKENS = 1024; private static final int SCAN_ROWS = 10; - private static final String SCAN_RANGE = "A1:Z10"; private final Sheets googleSheetsService; private final ClaudeProperties claudeProperties; @@ -48,28 +49,55 @@ public SheetHeaderMapper( this.restClient = restClientBuilder.build(); } - public SheetColumnMapping analyzeHeaders(String spreadsheetId) { - List> rows = readRows(spreadsheetId); - if (rows.isEmpty()) { - log.warn("No data found in spreadsheet. Using default mapping."); - return SheetColumnMapping.defaultMapping(); + public record SheetAnalysisResult( + SheetColumnMapping memberListMapping, + Integer feeSheetId, + SheetColumnMapping feeLedgerMapping + ) {} + + public SheetAnalysisResult analyzeAllSheets(String spreadsheetId) { + List sheets = readAllSheets(spreadsheetId); + if (sheets.isEmpty()) { + log.warn("No sheets found. Using default mapping."); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); } try { - return inferMapping(rows); + return inferAllMappings(spreadsheetId, sheets); } catch (Exception e) { - log.warn( - "Header analysis failed, using default mapping. cause={}", - e.getMessage() - ); - return SheetColumnMapping.defaultMapping(); + log.warn("Sheet analysis failed, using default. cause={}", e.getMessage()); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); } } - private List> readRows(String spreadsheetId) { + private record SheetInfo(Integer sheetId, String title) {} + + private List readAllSheets(String spreadsheetId) { try { + Spreadsheet spreadsheet = googleSheetsService.spreadsheets() + .get(spreadsheetId) + .execute(); + + List result = new ArrayList<>(); + for (Sheet sheet : spreadsheet.getSheets()) { + result.add(new SheetInfo( + sheet.getProperties().getSheetId(), + sheet.getProperties().getTitle() + )); + } + return result; + + } catch (IOException e) { + log.error("Failed to read spreadsheet info. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private List> readSheetRows(String spreadsheetId, String sheetTitle) { + try { + String range = "'" + sheetTitle + "'!A1:Z10"; ValueRange response = googleSheetsService.spreadsheets().values() - .get(spreadsheetId, SCAN_RANGE) + .get(spreadsheetId, range) .execute(); List> values = response.getValues(); @@ -88,48 +116,75 @@ private List> readRows(String spreadsheetId) { return rows; } catch (IOException e) { - log.error("Failed to read rows. spreadsheetId={}", spreadsheetId, e); + log.warn("Failed to read rows from sheet '{}'. cause={}", sheetTitle, e.getMessage()); return List.of(); } } - private SheetColumnMapping inferMapping(List> rows) throws Exception { - String prompt = buildPrompt(rows); - String rawJson = callClaude(prompt); - return parseMapping(rawJson); - } + private SheetAnalysisResult inferAllMappings( + String spreadsheetId, + List sheets + ) throws Exception { + StringBuilder sheetsDescription = new StringBuilder(); + Map>> sheetRowsMap = new HashMap<>(); - private String buildPrompt(List> rows) { - StringBuilder rowsDescription = new StringBuilder(); - for (int i = 0; i < rows.size(); i++) { - rowsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + for (SheetInfo sheet : sheets) { + List> rows = readSheetRows(spreadsheetId, sheet.title()); + sheetRowsMap.put(sheet.title(), rows); + + sheetsDescription.append(String.format("=== Sheet: \"%s\" (sheetId: %d) ===%n", + sheet.title(), sheet.sheetId())); + if (rows.isEmpty()) { + sheetsDescription.append("(empty)\n"); + } else { + for (int i = 0; i < rows.size(); i++) { + sheetsDescription.append(String.format("Row %d: %s%n", i + 1, rows.get(i))); + } + } + sheetsDescription.append("\n"); } + String prompt = buildPrompt(sheetsDescription.toString(), sheets); + String rawJson = callClaude(prompt); + return parseAllMappings(rawJson, sheets); + } + + private String buildPrompt(String sheetsDescription, List sheets) { + List sheetNames = sheets.stream().map(SheetInfo::title).toList(); return String.format(""" - Below are the first rows of a spreadsheet used by a Korean university club: + A Korean university club uses a Google Spreadsheet with these sheets: + %s %s - First, identify which row contains the column headers (not title or blank rows). - Then, map each header to one of these field names. Column index starts at 0. - Fields: name, studentId, email, phone, position, joinedAt, feePaid, paidAt + + Analyze the sheets and respond ONLY with a JSON object in this format: + { + "memberList": { + "sheetTitle": "sheet name containing member list", + "headerRow": 1, + "mapping": {"name": 0, "studentId": 1, "email": 2} + }, + "feeLedger": { + "sheetTitle": "sheet name containing fee payment info, or null if none", + "headerRow": 1, + "mapping": {"name": 0, "feePaid": 1, "paidAt": 2} + } + } + + Field definitions: + - memberList fields: name(이름/성명), studentId(학번), email(이메일), phone(전화번호/연락처), position(직책), joinedAt(가입일), feePaid(납부여부), paidAt(납부일) + - feeLedger fields: name(이름/성명), feePaid(납부여부/회비), paidAt(납부일), studentId(학번) Rules: - - "name" = member's name (이름, 성명 etc.) - - "studentId" = student number (학번, 학생번호 etc.) - - "email" = email address (이메일, 이메일주소 etc.) - - "phone" = phone number (전화번호, 연락처, 핸드폰 etc.) - - "position" = role in club (직책, 직급, 역할 etc.) - - "joinedAt" = join date (가입일, 가입날짜, 입부일 etc.) - - "feePaid" = fee payment status (회비, 납부여부, 회비납부 etc.) - - "paidAt" = fee payment date (납부일, 납부날짜 etc.) - - Respond ONLY with a JSON object in this exact format: - {"headerRow": 1, "mapping": {"name": 0, "studentId": 1}} - - - "headerRow" is the 1-indexed row number of the header row. - - "mapping" contains only fields you are confident about. - - Do not include any explanation. - """, rowsDescription); + - "memberList.sheetTitle" must be one of: %s + - "feeLedger.sheetTitle" must be one of: %s (or null if no fee-related sheet exists) + - "headerRow" is 1-indexed + - "mapping" uses 0-indexed column positions + - Only include fields you are confident about + - Do not include explanation + """, + sheetNames, sheetsDescription, sheetNames, sheetNames + ); } private String callClaude(String prompt) { @@ -157,39 +212,65 @@ private String callClaude(String prompt) { } } - private SheetColumnMapping parseMapping(String rawJson) { + private SheetAnalysisResult parseAllMappings( + String rawJson, + List sheets + ) { try { String cleaned = rawJson.trim(); int start = cleaned.indexOf('{'); int end = cleaned.lastIndexOf('}'); if (start < 0 || end < 0) { - throw new IllegalArgumentException("No JSON object found in response"); + throw new IllegalArgumentException("No JSON object found"); } cleaned = cleaned.substring(start, end + 1); JsonNode root = objectMapper.readTree(cleaned); - int headerRow = root.path("headerRow").asInt(1); - int dataStartRow = headerRow + 1; - JsonNode mappingNode = root.path("mapping"); - Map mapping = new HashMap<>(); + SheetColumnMapping memberListMapping = parseSingleMapping(root.path("memberList")); + SheetColumnMapping feeLedgerMapping = null; + Integer feeSheetId = null; - mappingNode.fields().forEachRemaining(entry -> { - int colIndex = entry.getValue().asInt(-1); - if (colIndex >= 0) { - mapping.put(entry.getKey(), colIndex); + JsonNode feeLedgerNode = root.path("feeLedger"); + if (!feeLedgerNode.isMissingNode() && !feeLedgerNode.isNull()) { + String feeLedgerTitle = feeLedgerNode.path("sheetTitle").asText(null); + if (feeLedgerTitle != null && !feeLedgerTitle.equals("null")) { + feeLedgerMapping = parseSingleMapping(feeLedgerNode); + feeSheetId = sheets.stream() + .filter(s -> s.title().equals(feeLedgerTitle)) + .map(SheetInfo::sheetId) + .findFirst() + .orElse(null); } - }); + } log.info( - "Sheet header mapping resolved. headerRow={}, dataStartRow={}, mapping={}", - headerRow, dataStartRow, mapping + "Sheet analysis done. memberList={}, feeSheetId={}, feeLedger={}", + memberListMapping.toMap(), feeSheetId, + feeLedgerMapping != null ? feeLedgerMapping.toMap() : "none" ); - return new SheetColumnMapping(mapping, dataStartRow); + + return new SheetAnalysisResult(memberListMapping, feeSheetId, feeLedgerMapping); } catch (Exception e) { - log.warn("Failed to parse mapping JSON: {}. Using default.", rawJson); - return SheetColumnMapping.defaultMapping(); + log.warn("Failed to parse all mappings: {}. Using default.", rawJson); + return new SheetAnalysisResult(SheetColumnMapping.defaultMapping(), null, null); } } + + private SheetColumnMapping parseSingleMapping(JsonNode node) { + int headerRow = node.path("headerRow").asInt(1); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = node.path("mapping"); + Map mapping = new HashMap<>(); + mappingNode.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0) { + mapping.put(entry.getKey(), colIndex); + } + }); + + return new SheetColumnMapping(mapping, dataStartRow); + } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index eb9f392a..c81a8192 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -86,6 +86,10 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as clearAndWriteAll(spreadsheetId, sorted, paymentMap); applyFormat(spreadsheetId); } + if (club.getFeeSheetId() != null && club.getFeeSheetColumnMapping() != null) { + SheetColumnMapping feeMapping = resolveRawMapping(club.getFeeSheetColumnMapping()); + syncFeeLedger(spreadsheetId, club.getFeeSheetId(), sorted, paymentMap, feeMapping); + } } catch (IOException e) { log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", @@ -96,11 +100,7 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } - private SheetColumnMapping resolveMapping(Club club) { - String mappingJson = club.getSheetColumnMapping(); - if (mappingJson == null || mappingJson.isBlank()) { - return SheetColumnMapping.defaultMapping(); - } + private SheetColumnMapping resolveRawMapping(String mappingJson) { try { Map raw = objectMapper.readValue( mappingJson, new TypeReference<>() {} @@ -115,9 +115,64 @@ private SheetColumnMapping resolveMapping(Club club) { }); return new SheetColumnMapping(fieldMap, dataStartRow); } catch (Exception e) { - log.warn("Failed to parse sheet mapping, using default. cause={}", e.getMessage()); + log.warn("Failed to parse raw mapping, using default. cause={}", e.getMessage()); + return SheetColumnMapping.defaultMapping(); + } + } + + private void syncFeeLedger( + String spreadsheetId, + Integer feeSheetId, + List members, + Map paymentMap, + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = mapping.getDataStartRow(); + Map> columnData = buildColumnData(members, paymentMap, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + String colLetter = columnLetter(colIndex); + String range = String.format("'%s'!%s%d:%s", + getSheetTitle(spreadsheetId, feeSheetId), + colLetter, dataStartRow, colLetter); + List> wrapped = + entry.getValue().stream().map(v -> List.of((Object)v)).toList(); + data.add(new ValueRange().setRange(range).setValues(wrapped)); + } + + if (!data.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchUpdate(spreadsheetId, + new BatchUpdateValuesRequest() + .setValueInputOption("USER_ENTERED") + .setData(data)) + .execute(); + log.info("Fee ledger synced. feeSheetId={}, members={}", feeSheetId, members.size()); + } + } + + private String getSheetTitle(String spreadsheetId, Integer sheetId) { + try { + return googleSheetsService.spreadsheets().get(spreadsheetId).execute() + .getSheets().stream() + .filter(s -> s.getProperties().getSheetId().equals(sheetId)) + .map(s -> s.getProperties().getTitle()) + .findFirst() + .orElse("Sheet1"); + } catch (IOException e) { + log.warn("Failed to get sheet title for sheetId={}", sheetId); + return "Sheet1"; + } + } + + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { return SheetColumnMapping.defaultMapping(); } + return resolveRawMapping(mappingJson); } private void updateMappedColumns( diff --git a/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql b/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql new file mode 100644 index 00000000..8ff2b6e0 --- /dev/null +++ b/src/main/resources/db/migration/V53__add_fee_sheet_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN fee_sheet_id INT NULL, + ADD COLUMN fee_sheet_column_mapping JSON NULL; From 5588571af76e53b919f9f5b867e76c9d91a0f6d8 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 10:30:09 +0900 Subject: [PATCH 20/33] =?UTF-8?q?fix:=20Checkstyle=20-=20UnusedImports,=20?= =?UTF-8?q?LineLength,=20EqualsAvoidNull=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/club/service/ClubMemberSheetService.java | 1 - .../agit/konect/domain/club/service/SheetHeaderMapper.java | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index a7c81d6c..520bcdbd 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -16,7 +16,6 @@ import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.model.SheetColumnMapping; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import gg.agit.konect.global.exception.CustomException; diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index f6a429b3..d26e2fae 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -173,7 +173,8 @@ private String buildPrompt(String sheetsDescription, List sheets) { Field definitions: - memberList fields: name(이름/성명), studentId(학번), email(이메일), phone(전화번호/연락처), position(직책), joinedAt(가입일), feePaid(납부여부), paidAt(납부일) - - feeLedger fields: name(이름/성명), feePaid(납부여부/회비), paidAt(납부일), studentId(학번) + - feeLedger fields: name(이름/성명), feePaid(납부여부/회비), + paidAt(납부일), studentId(학번) Rules: - "memberList.sheetTitle" must be one of: %s @@ -234,7 +235,7 @@ private SheetAnalysisResult parseAllMappings( JsonNode feeLedgerNode = root.path("feeLedger"); if (!feeLedgerNode.isMissingNode() && !feeLedgerNode.isNull()) { String feeLedgerTitle = feeLedgerNode.path("sheetTitle").asText(null); - if (feeLedgerTitle != null && !feeLedgerTitle.equals("null")) { + if (feeLedgerTitle != null && !"null".equals(feeLedgerTitle)) { feeLedgerMapping = parseSingleMapping(feeLedgerNode); feeSheetId = sheets.stream() .filter(s -> s.title().equals(feeLedgerTitle)) From 8e87850c906e6229006ffd3fd102db736e636c8a Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 10:31:46 +0900 Subject: [PATCH 21/33] =?UTF-8?q?fix:=20Checkstyle=20LineLength=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20memberList=20fields=20=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/service/SheetHeaderMapper.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index d26e2fae..88afe22a 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -172,7 +172,9 @@ private String buildPrompt(String sheetsDescription, List sheets) { } Field definitions: - - memberList fields: name(이름/성명), studentId(학번), email(이메일), phone(전화번호/연락처), position(직책), joinedAt(가입일), feePaid(납부여부), paidAt(납부일) + - memberList fields: name(이름/성명), studentId(학번), email(이메일), + phone(전화번호/연락처), position(직책), joinedAt(가입일), + feePaid(납부여부), paidAt(납부일) - feeLedger fields: name(이름/성명), feePaid(납부여부/회비), paidAt(납부일), studentId(학번) From 4b39e1753fcdeade362b8ceea5d46fda7e856a0c Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:01:42 +0900 Subject: [PATCH 22/33] =?UTF-8?q?feat:=20Drive=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20+=20=EC=97=AD=EB=B0=A9=ED=96=A5=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=20+=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=ED=8C=80=20=EC=96=91=EC=8B=9D=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../controller/ClubSheetMigrationApi.java | 48 ++++ .../ClubSheetMigrationController.java | 50 ++++ .../domain/club/dto/SheetImportRequest.java | 14 + .../domain/club/dto/SheetImportResponse.java | 9 + .../domain/club/dto/SheetMigrateRequest.java | 14 + .../agit/konect/domain/club/model/Club.java | 10 + .../club/service/SheetImportService.java | 122 +++++++++ .../club/service/SheetMigrationService.java | 256 ++++++++++++++++++ .../googlesheets/GoogleSheetsConfig.java | 32 ++- .../resources/application-infrastructure.yml | 1 + ...add_drive_and_template_columns_to_club.sql | 3 + 12 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java create mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java create mode 100644 src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java create mode 100644 src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java create mode 100644 src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql diff --git a/build.gradle b/build.gradle index 72fcf961..90b59564 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,9 @@ dependencies { implementation 'com.google.apis:google-api-services-sheets:v4-rev20251110-2.0.0' implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' + // Google Drive API + implementation 'com.google.apis:google-api-services-drive:v3-rev20250218-2.0.0' + // Gemini AI - using REST API directly (no SDK dependency) // test 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 new file mode 100644 index 00000000..e8aaaf2f --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationApi.java @@ -0,0 +1,48 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubSheetMigrationApi { + + @Operation( + summary = "기존 스프레드시트 → 팀 양식으로 이관", + description = "동아리가 기존에 사용하던 스프레드시트 URL을 제출하면, " + + "AI가 데이터를 분석하여 KONECT 팀이 마련한 표준 양식 파일로 복사합니다. " + + "새 파일은 기존 URL과 동일한 Google Drive 폴더에 생성됩니다. " + + "이후 동기화는 새로 생성된 파일 기준으로 진행됩니다." + ) + @PostMapping("/{clubId}/sheet/migrate") + ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "기존 스프레드시트에서 사전 회원 가져오기", + description = "동아리가 기존에 관리하던 스프레드시트의 인명부를 읽어 " + + "DB에 사전 회원(ClubPreMember)으로 등록합니다. " + + "AI가 헤더를 자동 분석하며, 이미 등록된 회원(이름+학번 중복)은 건너뜁니다." + ) + @PostMapping("/{clubId}/sheet/import") + ResponseEntity importPreMembers( + @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 new file mode 100644 index 00000000..ffde170c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubSheetMigrationController.java @@ -0,0 +1,50 @@ +package gg.agit.konect.domain.club.controller; + +import org.springframework.http.ResponseEntity; +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.RestController; + +import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; +import gg.agit.konect.domain.club.dto.SheetImportRequest; +import gg.agit.konect.domain.club.dto.SheetImportResponse; +import gg.agit.konect.domain.club.dto.SheetMigrateRequest; +import gg.agit.konect.domain.club.service.SheetImportService; +import gg.agit.konect.domain.club.service.SheetMigrationService; +import gg.agit.konect.global.auth.annotation.UserId; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +public class ClubSheetMigrationController implements ClubSheetMigrationApi { + + private final SheetMigrationService sheetMigrationService; + private final SheetImportService sheetImportService; + + @Override + public ResponseEntity migrateSheet( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetMigrateRequest request, + @UserId Integer requesterId + ) { + String newSpreadsheetId = sheetMigrationService.migrateToTemplate( + clubId, requesterId, request.sourceSpreadsheetUrl() + ); + return ResponseEntity.ok(ClubMemberSheetSyncResponse.of(0, newSpreadsheetId)); + } + + @Override + public ResponseEntity importPreMembers( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody SheetImportRequest request, + @UserId Integer requesterId + ) { + int count = sheetImportService.importPreMembersFromSheet( + clubId, requesterId, request.spreadsheetId() + ); + return ResponseEntity.ok(SheetImportResponse.of(count)); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java new file mode 100644 index 00000000..bc87df24 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record SheetImportRequest( + @NotBlank + @Schema( + description = "인명부가 담긴 구글 스프레드시트 ID 또는 URL", + example = "1BxiMVs0XRA5..." + ) + String spreadsheetId +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java new file mode 100644 index 00000000..91225711 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetImportResponse.java @@ -0,0 +1,9 @@ +package gg.agit.konect.domain.club.dto; + +public record SheetImportResponse( + int importedCount +) { + public static SheetImportResponse of(int importedCount) { + return new SheetImportResponse(importedCount); + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java new file mode 100644 index 00000000..41d99ad7 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/dto/SheetMigrateRequest.java @@ -0,0 +1,14 @@ +package gg.agit.konect.domain.club.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record SheetMigrateRequest( + @NotBlank + @Schema( + description = "동아리가 기존에 사용하던 구글 스프레드시트 URL", + example = "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5.../edit" + ) + String sourceSpreadsheetUrl +) { +} diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index c69c65bb..5889e802 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -96,6 +96,12 @@ public class Club extends BaseEntity { @Column(name = "fee_sheet_column_mapping", columnDefinition = "JSON") private String feeSheetColumnMapping; + @Column(name = "drive_folder_id", length = 255) + private String driveFolderId; + + @Column(name = "template_spreadsheet_id", length = 255) + private String templateSpreadsheetId; + @OneToOne(mappedBy = "club", fetch = LAZY, cascade = ALL, orphanRemoval = true) private ClubRecruitment clubRecruitment; @@ -249,4 +255,8 @@ public void updateFeeSheet(Integer feeSheetId, String feeSheetColumnMapping) { this.feeSheetId = feeSheetId; this.feeSheetColumnMapping = feeSheetColumnMapping; } + + public void updateDriveFolderId(String driveFolderId) { + this.driveFolderId = driveFolderId; + } } 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 new file mode 100644 index 00000000..3f627f65 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetImportService.java @@ -0,0 +1,122 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubPosition; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubPreMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubPreMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetImportService { + + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + private final ClubPreMemberRepository clubPreMemberRepository; + private final ClubPermissionValidator clubPermissionValidator; + + @Transactional + public int importPreMembersFromSheet( + Integer clubId, + Integer requesterId, + String spreadsheetId + ) { + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + Club club = clubRepository.getById(clubId); + + SheetHeaderMapper.SheetAnalysisResult analysis = + sheetHeaderMapper.analyzeAllSheets(spreadsheetId); + SheetColumnMapping mapping = analysis.memberListMapping(); + + List> rows = readDataRows(spreadsheetId, mapping); + int imported = 0; + + for (List row : rows) { + String name = getCell(row, mapping, SheetColumnMapping.NAME); + String studentNumber = getCell(row, mapping, SheetColumnMapping.STUDENT_ID); + + if (name.isBlank() || studentNumber.isBlank()) { + continue; + } + + if (clubPreMemberRepository.existsByClubIdAndStudentNumberAndName( + clubId, studentNumber, name + )) { + continue; + } + + String positionStr = getCell(row, mapping, SheetColumnMapping.POSITION); + ClubPosition position = resolvePosition(positionStr); + + ClubPreMember preMember = ClubPreMember.builder() + .club(club) + .studentNumber(studentNumber) + .name(name) + .clubPosition(position) + .build(); + + clubPreMemberRepository.save(preMember); + imported++; + } + + log.info( + "Sheet import done. clubId={}, spreadsheetId={}, imported={}", + clubId, spreadsheetId, imported + ); + return imported; + } + + private List> readDataRows(String spreadsheetId, SheetColumnMapping mapping) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read sheet data. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private String getCell(List row, SheetColumnMapping mapping, String field) { + int col = mapping.getColumnIndex(field); + if (col < 0 || col >= row.size()) { + return ""; + } + String value = row.get(col).toString().trim(); + if (value.startsWith("'")) { + return value.substring(1); + } + return value; + } + + private ClubPosition resolvePosition(String positionStr) { + for (ClubPosition pos : ClubPosition.values()) { + if (pos.getDescription().equals(positionStr) + || pos.name().equalsIgnoreCase(positionStr)) { + return pos; + } + } + return ClubPosition.MEMBER; + } +} diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java new file mode 100644 index 00000000..c38c7459 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -0,0 +1,256 @@ +package gg.agit.konect.domain.club.service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.sheets.v4.Sheets; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubRepository; +import gg.agit.konect.global.exception.CustomException; +import gg.agit.konect.global.code.ApiResponseCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SheetMigrationService { + + private static final Pattern FOLDER_ID_PATTERN = + Pattern.compile("(?:folders/|id=)([a-zA-Z0-9_-]{20,})"); + private static final Pattern SPREADSHEET_ID_PATTERN = + Pattern.compile("/spreadsheets/d/([a-zA-Z0-9_-]+)"); + private static final String MIME_TYPE_SPREADSHEET = + "application/vnd.google-apps.spreadsheet"; + private static final String NEW_SHEET_TITLE_PREFIX = "KONECT_인명부_"; + + @Value("${google.sheets.template-spreadsheet-id:}") + private String defaultTemplateSpreadsheetId; + + private final Drive googleDriveService; + private final Sheets googleSheetsService; + private final SheetHeaderMapper sheetHeaderMapper; + private final ClubRepository clubRepository; + + @Transactional + public String migrateToTemplate( + Integer clubId, + Integer requesterId, + String sourceSpreadsheetUrl + ) { + Club club = clubRepository.getById(clubId); + String templateId = defaultTemplateSpreadsheetId; + + if (templateId == null || templateId.isBlank()) { + throw CustomException.of(ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID); + } + + String sourceSpreadsheetId = extractSpreadsheetId(sourceSpreadsheetUrl); + String folderId = resolveFolderId(sourceSpreadsheetUrl, sourceSpreadsheetId); + + String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId); + + SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = + sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); + + List> sourceData = readAllData( + sourceSpreadsheetId, + sourceAnalysis.memberListMapping() + ); + + writeToTemplate(newSpreadsheetId, sourceData, sourceAnalysis.memberListMapping()); + + club.updateGoogleSheetId(newSpreadsheetId); + if (folderId != null) { + club.updateDriveFolderId(folderId); + } + + SheetHeaderMapper.SheetAnalysisResult newAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + try { + com.fasterxml.jackson.databind.ObjectMapper om = + new com.fasterxml.jackson.databind.ObjectMapper(); + club.updateSheetColumnMapping( + om.writeValueAsString(newAnalysis.memberListMapping().toMap()) + ); + } catch (Exception e) { + log.warn("Failed to serialize new mapping. cause={}", e.getMessage()); + } + + log.info( + "Sheet migration done. clubId={}, sourceId={}, newId={}, folderId={}", + clubId, sourceSpreadsheetId, newSpreadsheetId, folderId + ); + + return newSpreadsheetId; + } + + private String extractSpreadsheetId(String url) { + Matcher m = SPREADSHEET_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + return url; + } + + private String resolveFolderId(String url, String spreadsheetId) { + Matcher m = FOLDER_ID_PATTERN.matcher(url); + if (m.find()) { + return m.group(1); + } + try { + File file = googleDriveService.files().get(spreadsheetId) + .setFields("parents") + .execute(); + List parents = file.getParents(); + if (parents != null && !parents.isEmpty()) { + return parents.get(0); + } + } catch (IOException e) { + log.warn("Failed to get parent folder of spreadsheet. cause={}", e.getMessage()); + } + return null; + } + + private String copyTemplate(String templateId, String clubName, String targetFolderId) { + try { + String title = NEW_SHEET_TITLE_PREFIX + clubName; + File copyMetadata = new File().setName(title); + + if (targetFolderId != null) { + copyMetadata.setParents(Collections.singletonList(targetFolderId)); + } + + File copied = googleDriveService.files().copy(templateId, copyMetadata) + .setFields("id") + .execute(); + + log.info("Template copied. newId={}, folderId={}", copied.getId(), targetFolderId); + return copied.getId(); + + } catch (IOException e) { + log.error("Failed to copy template. cause={}", e.getMessage(), e); + throw new RuntimeException("Failed to copy template spreadsheet", e); + } + } + + private List> readAllData( + String spreadsheetId, + SheetColumnMapping mapping + ) { + try { + int dataStartRow = mapping.getDataStartRow(); + String range = "A" + dataStartRow + ":Z"; + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, range) + .execute(); + + List> values = response.getValues(); + return values != null ? values : List.of(); + + } catch (IOException e) { + log.error("Failed to read source data. cause={}", e.getMessage(), e); + return List.of(); + } + } + + private void writeToTemplate( + String newSpreadsheetId, + List> sourceData, + SheetColumnMapping sourceMapping + ) { + if (sourceData.isEmpty()) { + return; + } + + try { + SheetHeaderMapper.SheetAnalysisResult templateAnalysis = + sheetHeaderMapper.analyzeAllSheets(newSpreadsheetId); + SheetColumnMapping targetMapping = templateAnalysis.memberListMapping(); + int targetDataStartRow = targetMapping.getDataStartRow(); + + Map sourceFieldToCol = buildReverseMapping(sourceMapping); + List> targetRows = new ArrayList<>(); + + for (List sourceRow : sourceData) { + List targetRow = buildTargetRow( + sourceRow, sourceFieldToCol, targetMapping + ); + targetRows.add(targetRow); + } + + String range = "A" + targetDataStartRow; + ValueRange body = new ValueRange().setValues(targetRows); + googleSheetsService.spreadsheets().values() + .update(newSpreadsheetId, range, body) + .setValueInputOption("USER_ENTERED") + .execute(); + + log.info( + "Data written to template. rows={}, targetStartRow={}", + targetRows.size(), targetDataStartRow + ); + + } catch (IOException e) { + log.error("Failed to write data to template. cause={}", e.getMessage(), e); + } + } + + private Map buildReverseMapping(SheetColumnMapping mapping) { + Map result = new java.util.HashMap<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, + SheetColumnMapping.EMAIL, SheetColumnMapping.PHONE, + SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT, + SheetColumnMapping.FEE_PAID, SheetColumnMapping.PAID_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + result.put(field, colIndex); + } + } + return result; + } + + private List buildTargetRow( + List sourceRow, + Map sourceFieldToCol, + SheetColumnMapping targetMapping + ) { + int maxCol = targetMapping.toMap().values().stream() + .filter(v -> v instanceof Integer) + .mapToInt(v -> (Integer)v) + .max() + .orElse(0); + + List row = new ArrayList<>( + Collections.nCopies(maxCol + 1, "") + ); + + for (Map.Entry entry : sourceFieldToCol.entrySet()) { + String field = entry.getKey(); + int sourceCol = entry.getValue(); + int targetCol = targetMapping.getColumnIndex(field); + + if (targetCol >= 0 && sourceCol < sourceRow.size()) { + row.set(targetCol, sourceRow.get(sourceCol)); + } + } + + return row; + } +} diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index fb8b6da8..3e6b4eb7 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -4,13 +4,15 @@ import java.io.IOException; import java.io.InputStream; import java.security.GeneralSecurityException; -import java.util.Collections; +import java.util.Arrays; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.SheetsScopes; import com.google.auth.http.HttpCredentialsAdapter; @@ -25,15 +27,35 @@ public class GoogleSheetsConfig { private final GoogleSheetsProperties googleSheetsProperties; @Bean - public Sheets googleSheetsService() throws IOException, GeneralSecurityException { + public GoogleCredentials googleCredentials() throws IOException { InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath()); - GoogleCredentials credentials = GoogleCredentials.fromStream(in) - .createScoped(Collections.singleton(SheetsScopes.SPREADSHEETS)); + return GoogleCredentials.fromStream(in) + .createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } + @Bean + public Sheets googleSheetsService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { return new Sheets.Builder( GoogleNetHttpTransport.newTrustedTransport(), GsonFactory.getDefaultInstance(), - new HttpCredentialsAdapter(credentials)) + new HttpCredentialsAdapter(googleCredentials)) + .setApplicationName(googleSheetsProperties.applicationName()) + .build(); + } + + @Bean + public Drive googleDriveService( + GoogleCredentials googleCredentials + ) throws IOException, GeneralSecurityException { + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(googleCredentials)) .setApplicationName(googleSheetsProperties.applicationName()) .build(); } diff --git a/src/main/resources/application-infrastructure.yml b/src/main/resources/application-infrastructure.yml index 7e3da81d..a92a092a 100644 --- a/src/main/resources/application-infrastructure.yml +++ b/src/main/resources/application-infrastructure.yml @@ -25,3 +25,4 @@ google: sheets: credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH} application-name: ${GOOGLE_SHEETS_APP_NAME:KONECT} + template-spreadsheet-id: ${GOOGLE_SHEETS_TEMPLATE_ID:} diff --git a/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql b/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql new file mode 100644 index 00000000..4bdaf225 --- /dev/null +++ b/src/main/resources/db/migration/V54__add_drive_and_template_columns_to_club.sql @@ -0,0 +1,3 @@ +ALTER TABLE club + ADD COLUMN drive_folder_id VARCHAR(255) NULL, + ADD COLUMN template_spreadsheet_id VARCHAR(255) NULL; From 42bde6eede5c1623b123668db5a3c55ca7c21d87 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:09:03 +0900 Subject: [PATCH 23/33] =?UTF-8?q?fix:=20Google=20Drive=20API=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=88=98=EC=A0=95=20-=20v3-rev20250723-2.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 90b59564..6d8b818f 100644 --- a/build.gradle +++ b/build.gradle @@ -80,7 +80,7 @@ dependencies { implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' // Google Drive API - implementation 'com.google.apis:google-api-services-drive:v3-rev20250218-2.0.0' + implementation 'com.google.apis:google-api-services-drive:v3-rev20250723-2.0.0' // Gemini AI - using REST API directly (no SDK dependency) From 771b313a4e8d26346a47c66e34abf129de4ab216 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:09:49 +0900 Subject: [PATCH 24/33] =?UTF-8?q?fix:=20Checkstyle=20UnusedImports=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gg/agit/konect/domain/club/service/SheetImportService.java | 1 - 1 file changed, 1 deletion(-) 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 3f627f65..7ab32742 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 @@ -1,7 +1,6 @@ package gg.agit.konect.domain.club.service; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Service; From 3023aed8111c37fbbebe62fcdf78e011632b2769 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:51:15 +0900 Subject: [PATCH 25/33] =?UTF-8?q?revert:=20=ED=9A=8C=EB=B9=84=20=EB=82=A9?= =?UTF-8?q?=EB=B6=80(FeePayment)=20=EA=B8=B0=EB=8A=A5=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20-=20=ED=8C=80=20=EB=85=BC=EC=9D=98=20?= =?UTF-8?q?=ED=9B=84=20=EC=9E=AC=EB=8F=84=EC=9E=85=20=EC=98=88=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agit/konect/domain/club/model/Club.java | 11 -- .../domain/club/model/SheetColumnMapping.java | 6 - .../club/service/ClubMemberSheetService.java | 12 -- .../club/service/SheetHeaderMapper.java | 13 +- .../club/service/SheetMigrationService.java | 3 +- .../club/service/SheetSyncExecutor.java | 114 +++--------------- .../konect/global/code/ApiResponseCode.java | 3 - .../V55__rollback_fee_payment_feature.sql | 7 ++ 8 files changed, 26 insertions(+), 143 deletions(-) create mode 100644 src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql diff --git a/src/main/java/gg/agit/konect/domain/club/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 5889e802..114c1cc8 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/Club.java +++ b/src/main/java/gg/agit/konect/domain/club/model/Club.java @@ -90,12 +90,6 @@ public class Club extends BaseEntity { @Column(name = "sheet_column_mapping", columnDefinition = "JSON") private String sheetColumnMapping; - @Column(name = "fee_sheet_id") - private Integer feeSheetId; - - @Column(name = "fee_sheet_column_mapping", columnDefinition = "JSON") - private String feeSheetColumnMapping; - @Column(name = "drive_folder_id", length = 255) private String driveFolderId; @@ -251,11 +245,6 @@ public void updateSheetColumnMapping(String sheetColumnMapping) { this.sheetColumnMapping = sheetColumnMapping; } - public void updateFeeSheet(Integer feeSheetId, String feeSheetColumnMapping) { - this.feeSheetId = feeSheetId; - this.feeSheetColumnMapping = feeSheetColumnMapping; - } - public void updateDriveFolderId(String driveFolderId) { this.driveFolderId = driveFolderId; } diff --git a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java index 600cfde2..227a4de2 100644 --- a/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -11,8 +11,6 @@ public class SheetColumnMapping { public static final String PHONE = "phone"; public static final String POSITION = "position"; public static final String JOINED_AT = "joinedAt"; - public static final String FEE_PAID = "feePaid"; - public static final String PAID_AT = "paidAt"; private static final int COL_NAME = 0; private static final int COL_STUDENT_ID = 1; @@ -20,8 +18,6 @@ public class SheetColumnMapping { private static final int COL_PHONE = 3; private static final int COL_POSITION = 4; private static final int COL_JOINED_AT = 5; - private static final int COL_FEE_PAID = 6; - private static final int COL_PAID_AT = 7; private static final int DEFAULT_DATA_START_ROW = 2; private final Map fieldToColumn; @@ -44,8 +40,6 @@ public static SheetColumnMapping defaultMapping() { mapping.put(PHONE, COL_PHONE); mapping.put(POSITION, COL_POSITION); mapping.put(JOINED_AT, COL_JOINED_AT); - mapping.put(FEE_PAID, COL_FEE_PAID); - mapping.put(PAID_AT, COL_PAID_AT); return new SheetColumnMapping(mapping, DEFAULT_DATA_START_ROW); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index 520bcdbd..a6403091 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -13,7 +13,6 @@ import gg.agit.konect.domain.club.dto.ClubMemberSheetSyncResponse; import gg.agit.konect.domain.club.dto.ClubSheetIdUpdateRequest; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; -import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; import gg.agit.konect.domain.club.event.ClubMemberChangedEvent; import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.repository.ClubMemberRepository; @@ -40,11 +39,6 @@ public void onClubMemberChanged(ClubMemberChangedEvent event) { sheetSyncDebouncer.debounce(event.clubId()); } - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onClubFeePaymentApproved(ClubFeePaymentApprovedEvent event) { - sheetSyncDebouncer.debounce(event.clubId()); - } - @Transactional public void updateSheetId( Integer clubId, @@ -61,12 +55,6 @@ public void updateSheetId( club.updateSheetColumnMapping( objectMapper.writeValueAsString(result.memberListMapping().toMap()) ); - if (result.feeSheetId() != null && result.feeLedgerMapping() != null) { - club.updateFeeSheet( - result.feeSheetId(), - objectMapper.writeValueAsString(result.feeLedgerMapping().toMap()) - ); - } } catch (JsonProcessingException e) { log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 88afe22a..435f1902 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -163,30 +163,21 @@ private String buildPrompt(String sheetsDescription, List sheets) { "sheetTitle": "sheet name containing member list", "headerRow": 1, "mapping": {"name": 0, "studentId": 1, "email": 2} - }, - "feeLedger": { - "sheetTitle": "sheet name containing fee payment info, or null if none", - "headerRow": 1, - "mapping": {"name": 0, "feePaid": 1, "paidAt": 2} } } Field definitions: - memberList fields: name(이름/성명), studentId(학번), email(이메일), - phone(전화번호/연락처), position(직책), joinedAt(가입일), - feePaid(납부여부), paidAt(납부일) - - feeLedger fields: name(이름/성명), feePaid(납부여부/회비), - paidAt(납부일), studentId(학번) + phone(전화번호/연락처), position(직책), joinedAt(가입일) Rules: - "memberList.sheetTitle" must be one of: %s - - "feeLedger.sheetTitle" must be one of: %s (or null if no fee-related sheet exists) - "headerRow" is 1-indexed - "mapping" uses 0-indexed column positions - Only include fields you are confident about - Do not include explanation """, - sheetNames, sheetsDescription, sheetNames, sheetNames + sheetNames, sheetsDescription, sheetNames ); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index c38c7459..0b2ea2b2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -215,8 +215,7 @@ private Map buildReverseMapping(SheetColumnMapping mapping) { for (String field : List.of( SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, SheetColumnMapping.EMAIL, SheetColumnMapping.PHONE, - SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT, - SheetColumnMapping.FEE_PAID, SheetColumnMapping.PAID_AT + SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT )) { int colIndex = mapping.getColumnIndex(field); if (colIndex >= 0) { diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index c81a8192..ce8be8b2 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -7,7 +7,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -18,22 +17,20 @@ import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.BasicFilter; import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; import com.google.api.services.sheets.v4.model.ClearValuesRequest; import com.google.api.services.sheets.v4.model.GridProperties; import com.google.api.services.sheets.v4.model.GridRange; import com.google.api.services.sheets.v4.model.Request; import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; import com.google.api.services.sheets.v4.model.SheetProperties; -import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; import com.google.api.services.sheets.v4.model.ValueRange; import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.model.ClubFeePayment; import gg.agit.konect.domain.club.model.ClubMember; import gg.agit.konect.domain.club.model.SheetColumnMapping; -import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; import gg.agit.konect.domain.club.repository.ClubMemberRepository; import gg.agit.konect.domain.club.repository.ClubRepository; import lombok.RequiredArgsConstructor; @@ -49,10 +46,13 @@ public class SheetSyncExecutor { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final List HEADER_ROW = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt" + ); + private final Sheets googleSheetsService; private final ClubRepository clubRepository; private final ClubMemberRepository clubMemberRepository; - private final ClubFeePaymentRepository clubFeePaymentRepository; private final ObjectMapper objectMapper; @Async("sheetSyncTaskExecutor") @@ -72,24 +72,15 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as SheetColumnMapping mapping = resolveMapping(club); List members = clubMemberRepository.findAllByClubId(clubId); - List payments = clubFeePaymentRepository.findAllByClubId(clubId); - - Map paymentMap = payments.stream() - .collect(Collectors.toMap(p -> p.getUser().getId(), p -> p)); - - List sorted = sort(members, paymentMap, sortKey, ascending); + List sorted = sort(members, sortKey, ascending); try { if (club.getSheetColumnMapping() != null) { - updateMappedColumns(spreadsheetId, sorted, paymentMap, mapping); + updateMappedColumns(spreadsheetId, sorted, mapping); } else { - clearAndWriteAll(spreadsheetId, sorted, paymentMap); + clearAndWriteAll(spreadsheetId, sorted); applyFormat(spreadsheetId); } - if (club.getFeeSheetId() != null && club.getFeeSheetColumnMapping() != null) { - SheetColumnMapping feeMapping = resolveRawMapping(club.getFeeSheetColumnMapping()); - syncFeeLedger(spreadsheetId, club.getFeeSheetId(), sorted, paymentMap, feeMapping); - } } catch (IOException e) { log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", @@ -120,53 +111,6 @@ private SheetColumnMapping resolveRawMapping(String mappingJson) { } } - private void syncFeeLedger( - String spreadsheetId, - Integer feeSheetId, - List members, - Map paymentMap, - SheetColumnMapping mapping - ) throws IOException { - int dataStartRow = mapping.getDataStartRow(); - Map> columnData = buildColumnData(members, paymentMap, mapping); - - List data = new ArrayList<>(); - for (Map.Entry> entry : columnData.entrySet()) { - int colIndex = entry.getKey(); - String colLetter = columnLetter(colIndex); - String range = String.format("'%s'!%s%d:%s", - getSheetTitle(spreadsheetId, feeSheetId), - colLetter, dataStartRow, colLetter); - List> wrapped = - entry.getValue().stream().map(v -> List.of((Object)v)).toList(); - data.add(new ValueRange().setRange(range).setValues(wrapped)); - } - - if (!data.isEmpty()) { - googleSheetsService.spreadsheets().values() - .batchUpdate(spreadsheetId, - new BatchUpdateValuesRequest() - .setValueInputOption("USER_ENTERED") - .setData(data)) - .execute(); - log.info("Fee ledger synced. feeSheetId={}, members={}", feeSheetId, members.size()); - } - } - - private String getSheetTitle(String spreadsheetId, Integer sheetId) { - try { - return googleSheetsService.spreadsheets().get(spreadsheetId).execute() - .getSheets().stream() - .filter(s -> s.getProperties().getSheetId().equals(sheetId)) - .map(s -> s.getProperties().getTitle()) - .findFirst() - .orElse("Sheet1"); - } catch (IOException e) { - log.warn("Failed to get sheet title for sheetId={}", sheetId); - return "Sheet1"; - } - } - private SheetColumnMapping resolveMapping(Club club) { String mappingJson = club.getSheetColumnMapping(); if (mappingJson == null || mappingJson.isBlank()) { @@ -178,19 +122,18 @@ private SheetColumnMapping resolveMapping(Club club) { private void updateMappedColumns( String spreadsheetId, List members, - Map paymentMap, SheetColumnMapping mapping ) throws IOException { int dataStartRow = mapping.getDataStartRow(); - Map> columnData = buildColumnData(members, paymentMap, mapping); + Map> columnData = buildColumnData(members, mapping); List data = new ArrayList<>(); for (Map.Entry> entry : columnData.entrySet()) { int colIndex = entry.getKey(); - List values = entry.getValue(); String colLetter = columnLetter(colIndex); String range = colLetter + dataStartRow + ":" + colLetter; - List> wrapped = values.stream().map(v -> List.of((Object)v)).toList(); + List> wrapped = + entry.getValue().stream().map(v -> List.of((Object)v)).toList(); data.add(new ValueRange().setRange(range).setValues(wrapped)); } @@ -206,15 +149,11 @@ private void updateMappedColumns( private Map> buildColumnData( List members, - Map paymentMap, SheetColumnMapping mapping ) { Map> columns = new HashMap<>(); for (ClubMember member : members) { - Integer userId = member.getUser().getId(); - ClubFeePayment payment = paymentMap.get(userId); - putValue(columns, mapping, SheetColumnMapping.NAME, member.getUser().getName()); putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, @@ -228,11 +167,6 @@ private Map> buildColumnData( member.getClubPosition().getDescription()); putValue(columns, mapping, SheetColumnMapping.JOINED_AT, member.getCreatedAt().format(DATE_FORMATTER)); - putValue(columns, mapping, SheetColumnMapping.FEE_PAID, - payment != null && payment.isPaid() ? "Y" : "N"); - putValue(columns, mapping, SheetColumnMapping.PAID_AT, - payment != null && payment.getApprovedAt() != null - ? payment.getApprovedAt().format(DATE_FORMATTER) : ""); } return columns; @@ -252,38 +186,26 @@ private void putValue( private void clearAndWriteAll( String spreadsheetId, - List members, - Map paymentMap + List members ) throws IOException { - String clearRange = "A:H"; + String clearRange = "A:F"; googleSheetsService.spreadsheets().values() .clear(spreadsheetId, clearRange, new ClearValuesRequest()) .execute(); - List headerRow = List.of( - "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt", "FeePaid", "PaidAt" - ); List> rows = new ArrayList<>(); - rows.add(headerRow); + rows.add(HEADER_ROW); for (ClubMember member : members) { - Integer userId = member.getUser().getId(); - ClubFeePayment payment = paymentMap.get(userId); String phone = member.getUser().getPhoneNumber() != null ? "'" + member.getUser().getPhoneNumber() : ""; - String feePaid = payment != null && payment.isPaid() ? "Y" : "N"; - String paidAt = payment != null && payment.getApprovedAt() != null - ? payment.getApprovedAt().format(DATE_FORMATTER) : ""; - rows.add(List.of( member.getUser().getName(), member.getUser().getStudentNumber(), member.getUser().getEmail(), phone, member.getClubPosition().getDescription(), - member.getCreatedAt().format(DATE_FORMATTER), - feePaid, - paidAt + member.getCreatedAt().format(DATE_FORMATTER) )); } @@ -317,7 +239,6 @@ private void applyFormat(String spreadsheetId) throws IOException { private List sort( List members, - Map paymentMap, ClubSheetSortKey sortKey, boolean ascending ) { @@ -326,10 +247,7 @@ private List sort( case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); - case FEE_PAID -> Comparator.comparing(m -> { - ClubFeePayment p = paymentMap.get(m.getUser().getId()); - return p != null && p.isPaid() ? 0 : 1; - }); + case FEE_PAID -> Comparator.comparing(m -> m.getClubPosition().getPriority()); }; if (!ascending) { 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 5ad1ca83..33359c58 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -92,9 +92,6 @@ public enum ApiResponseCode { NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), - NOT_FOUND_FEE_PAYMENT(HttpStatus.NOT_FOUND, "회비 납부 내역을 찾을 수 없습니다."), - ALREADY_FEE_PAYMENT_SUBMITTED(HttpStatus.CONFLICT, "이미 회비 납부 내역이 접수되었습니다."), - ALREADY_FEE_PAYMENT_APPROVED(HttpStatus.CONFLICT, "이미 승인된 회비 납부 내역입니다."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드) METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드 입니다."), diff --git a/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql new file mode 100644 index 00000000..f4043644 --- /dev/null +++ b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql @@ -0,0 +1,7 @@ +-- V51 rollback: drop club_fee_payment table +DROP TABLE IF EXISTS club_fee_payment; + +-- V53 rollback: drop fee_sheet columns from club +ALTER TABLE club + DROP COLUMN IF EXISTS fee_sheet_id, + DROP COLUMN IF EXISTS fee_sheet_column_mapping; From dec6d8afa810af6b4dd2c8a637fe2670fe708edc Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:56:58 +0900 Subject: [PATCH 26/33] =?UTF-8?q?fix:=20FeePayment=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(git=20rm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/controller/ClubFeePaymentApi.java | 64 ------------- .../controller/ClubFeePaymentController.java | 63 ------------- .../club/dto/ClubFeePaymentResponse.java | 37 -------- .../club/dto/ClubFeePaymentSubmitRequest.java | 9 -- .../event/ClubFeePaymentApprovedEvent.java | 9 -- .../domain/club/model/ClubFeePayment.java | 75 --------------- .../repository/ClubFeePaymentRepository.java | 42 --------- .../club/service/ClubFeePaymentService.java | 92 ------------------- 8 files changed, 391 deletions(-) delete mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java delete mode 100644 src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java deleted file mode 100644 index 79a11181..00000000 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java +++ /dev/null @@ -1,64 +0,0 @@ -package gg.agit.konect.domain.club.controller; - -import java.util.List; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; - -import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; -import gg.agit.konect.domain.club.dto.ClubFeePaymentSubmitRequest; -import gg.agit.konect.global.auth.annotation.UserId; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "(Normal) Club - FeePayment") -@RequestMapping("/clubs") -public interface ClubFeePaymentApi { - - @Operation( - summary = "회비 납부 접수", - description = "회원이 회비를 납부했음을 접수합니다. 납부 증빙 이미지 URL을 함께 제출할 수 있습니다." - ) - @PostMapping("/{clubId}/fee-payments") - ResponseEntity submitFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @RequestBody ClubFeePaymentSubmitRequest request, - @UserId Integer requesterId - ); - - @Operation( - summary = "회비 납부 승인 (운영진 전용)", - description = "운영진이 특정 회원의 회비 납부를 승인합니다. " - + "승인 즉시 구글 스프레드시트의 해당 회원 납부 여부(FeePaid) 컬럼이 자동으로 업데이트됩니다." - ) - @PostMapping("/{clubId}/fee-payments/{targetUserId}/approve") - ResponseEntity approveFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @PathVariable(name = "targetUserId") Integer targetUserId, - @UserId Integer requesterId - ); - - @Operation( - summary = "전체 회비 납부 목록 조회 (운영진 전용)", - description = "동아리 전체 회원의 회비 납부 현황을 조회합니다. 납부 여부, 납부일, 증빙 이미지 URL을 확인할 수 있습니다." - ) - @GetMapping("/{clubId}/fee-payments") - ResponseEntity> getFeePayments( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer requesterId - ); - - @Operation( - summary = "내 회비 납부 상태 조회", - description = "로그인한 회원 본인의 회비 납부 상태를 조회합니다." - ) - @GetMapping("/{clubId}/fee-payments/me") - ResponseEntity getMyFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer requesterId - ); -} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java deleted file mode 100644 index 99ebebea..00000000 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java +++ /dev/null @@ -1,63 +0,0 @@ -package gg.agit.konect.domain.club.controller; - -import java.util.List; - -import org.springframework.http.ResponseEntity; -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.RestController; - -import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; -import gg.agit.konect.domain.club.dto.ClubFeePaymentSubmitRequest; -import gg.agit.konect.domain.club.service.ClubFeePaymentService; -import gg.agit.konect.global.auth.annotation.UserId; -import lombok.RequiredArgsConstructor; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/clubs") -public class ClubFeePaymentController implements ClubFeePaymentApi { - - private final ClubFeePaymentService clubFeePaymentService; - - @Override - public ResponseEntity submitFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @RequestBody ClubFeePaymentSubmitRequest request, - @UserId Integer requesterId - ) { - ClubFeePaymentResponse response = clubFeePaymentService.submitFeePayment( - clubId, requesterId, request.paymentImageUrl() - ); - return ResponseEntity.ok(response); - } - - @Override - public ResponseEntity approveFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @PathVariable(name = "targetUserId") Integer targetUserId, - @UserId Integer requesterId - ) { - ClubFeePaymentResponse response = clubFeePaymentService.approveFeePayment( - clubId, targetUserId, requesterId - ); - return ResponseEntity.ok(response); - } - - @Override - public ResponseEntity> getFeePayments( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer requesterId - ) { - return ResponseEntity.ok(clubFeePaymentService.getFeePayments(clubId, requesterId)); - } - - @Override - public ResponseEntity getMyFeePayment( - @PathVariable(name = "clubId") Integer clubId, - @UserId Integer requesterId - ) { - return ResponseEntity.ok(clubFeePaymentService.getMyFeePayment(clubId, requesterId)); - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java deleted file mode 100644 index 76784c33..00000000 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package gg.agit.konect.domain.club.dto; - -import java.time.LocalDateTime; - -import gg.agit.konect.domain.club.model.ClubFeePayment; -import io.swagger.v3.oas.annotations.media.Schema; - -public record ClubFeePaymentResponse( - @Schema(description = "User ID", example = "1") - Integer userId, - - @Schema(description = "Name", example = "John") - String userName, - - @Schema(description = "Student number", example = "2021136089") - String studentNumber, - - @Schema(description = "Payment status", example = "true") - boolean isPaid, - - @Schema(description = "Approved at") - LocalDateTime approvedAt, - - @Schema(description = "Payment image URL") - String paymentImageUrl -) { - public static ClubFeePaymentResponse from(ClubFeePayment payment) { - return new ClubFeePaymentResponse( - payment.getUser().getId(), - payment.getUser().getName(), - payment.getUser().getStudentNumber(), - payment.isPaid(), - payment.getApprovedAt(), - payment.getPaymentImageUrl() - ); - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java deleted file mode 100644 index 68c8d3fd..00000000 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package gg.agit.konect.domain.club.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record ClubFeePaymentSubmitRequest( - @Schema(description = "Payment image URL", example = "https://cdn.konect.com/fee/abc.jpg") - String paymentImageUrl -) { -} diff --git a/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java b/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java deleted file mode 100644 index bcd79126..00000000 --- a/src/main/java/gg/agit/konect/domain/club/event/ClubFeePaymentApprovedEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package gg.agit.konect.domain.club.event; - -public record ClubFeePaymentApprovedEvent( - Integer clubId -) { - public static ClubFeePaymentApprovedEvent of(Integer clubId) { - return new ClubFeePaymentApprovedEvent(clubId); - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java b/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java deleted file mode 100644 index b13fa14b..00000000 --- a/src/main/java/gg/agit/konect/domain/club/model/ClubFeePayment.java +++ /dev/null @@ -1,75 +0,0 @@ -package gg.agit.konect.domain.club.model; - -import static jakarta.persistence.FetchType.LAZY; -import static jakarta.persistence.GenerationType.IDENTITY; -import static lombok.AccessLevel.PROTECTED; - -import java.time.LocalDateTime; - -import gg.agit.konect.domain.user.model.User; -import gg.agit.konect.global.model.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Entity -@Table(name = "club_fee_payment") -@NoArgsConstructor(access = PROTECTED) -public class ClubFeePayment extends BaseEntity { - - @Id - @GeneratedValue(strategy = IDENTITY) - @Column(name = "id", nullable = false, updatable = false) - private Integer id; - - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "club_id", nullable = false, updatable = false) - private Club club; - - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "user_id", nullable = false, updatable = false) - private User user; - - @Column(name = "is_paid", nullable = false) - private boolean isPaid; - - @Column(name = "payment_image_url") - private String paymentImageUrl; - - @Column(name = "approved_at") - private LocalDateTime approvedAt; - - @ManyToOne(fetch = LAZY) - @JoinColumn(name = "approved_by") - private User approvedBy; - - @Builder - private ClubFeePayment(Club club, User user, String paymentImageUrl) { - this.club = club; - this.user = user; - this.isPaid = false; - this.paymentImageUrl = paymentImageUrl; - } - - public static ClubFeePayment of(Club club, User user, String paymentImageUrl) { - return ClubFeePayment.builder() - .club(club) - .user(user) - .paymentImageUrl(paymentImageUrl) - .build(); - } - - public void approve(User approver) { - this.isPaid = true; - this.approvedAt = LocalDateTime.now(); - this.approvedBy = approver; - } -} diff --git a/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java b/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java deleted file mode 100644 index f9cf7511..00000000 --- a/src/main/java/gg/agit/konect/domain/club/repository/ClubFeePaymentRepository.java +++ /dev/null @@ -1,42 +0,0 @@ -package gg.agit.konect.domain.club.repository; - -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.Repository; -import org.springframework.data.repository.query.Param; - -import gg.agit.konect.domain.club.model.ClubFeePayment; -import gg.agit.konect.global.code.ApiResponseCode; -import gg.agit.konect.global.exception.CustomException; - -public interface ClubFeePaymentRepository extends Repository { - - ClubFeePayment save(ClubFeePayment clubFeePayment); - - @Query(""" - SELECT fp - FROM ClubFeePayment fp - JOIN FETCH fp.user - WHERE fp.club.id = :clubId - AND fp.user.id = :userId - """) - Optional findByClubIdAndUserId( - @Param("clubId") Integer clubId, - @Param("userId") Integer userId - ); - - default ClubFeePayment getByClubIdAndUserId(Integer clubId, Integer userId) { - return findByClubIdAndUserId(clubId, userId) - .orElseThrow(() -> CustomException.of(ApiResponseCode.NOT_FOUND_FEE_PAYMENT)); - } - - @Query(""" - SELECT fp - FROM ClubFeePayment fp - JOIN FETCH fp.user - WHERE fp.club.id = :clubId - """) - List findAllByClubId(@Param("clubId") Integer clubId); -} diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java deleted file mode 100644 index 7edd01d7..00000000 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubFeePaymentService.java +++ /dev/null @@ -1,92 +0,0 @@ -package gg.agit.konect.domain.club.service; - -import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_FEE_PAYMENT; - -import java.util.List; - -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; -import gg.agit.konect.domain.club.event.ClubFeePaymentApprovedEvent; -import gg.agit.konect.domain.club.model.Club; -import gg.agit.konect.domain.club.model.ClubFeePayment; -import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; -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.exception.CustomException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ClubFeePaymentService { - - private final ClubRepository clubRepository; - private final ClubFeePaymentRepository clubFeePaymentRepository; - private final UserRepository userRepository; - private final ClubPermissionValidator clubPermissionValidator; - private final ApplicationEventPublisher applicationEventPublisher; - - @Transactional - public ClubFeePaymentResponse submitFeePayment( - Integer clubId, - Integer userId, - String paymentImageUrl - ) { - Club club = clubRepository.getById(clubId); - User user = userRepository.getById(userId); - - clubFeePaymentRepository.findByClubIdAndUserId(clubId, userId) - .ifPresent(p -> { - throw CustomException.of( - gg.agit.konect.global.code.ApiResponseCode.ALREADY_FEE_PAYMENT_SUBMITTED - ); - }); - - ClubFeePayment payment = ClubFeePayment.of(club, user, paymentImageUrl); - return ClubFeePaymentResponse.from(clubFeePaymentRepository.save(payment)); - } - - @Transactional - public ClubFeePaymentResponse approveFeePayment( - Integer clubId, - Integer targetUserId, - Integer requesterId - ) { - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - User approver = userRepository.getById(requesterId); - ClubFeePayment payment = clubFeePaymentRepository.getByClubIdAndUserId( - clubId, targetUserId - ); - - if (payment.isPaid()) { - throw CustomException.of( - gg.agit.konect.global.code.ApiResponseCode.ALREADY_FEE_PAYMENT_APPROVED - ); - } - - payment.approve(approver); - applicationEventPublisher.publishEvent(ClubFeePaymentApprovedEvent.of(clubId)); - - return ClubFeePaymentResponse.from(payment); - } - - public List getFeePayments(Integer clubId, Integer requesterId) { - clubRepository.getById(clubId); - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - - return clubFeePaymentRepository.findAllByClubId(clubId).stream() - .map(ClubFeePaymentResponse::from) - .toList(); - } - - public ClubFeePaymentResponse getMyFeePayment(Integer clubId, Integer userId) { - clubRepository.getById(clubId); - ClubFeePayment payment = clubFeePaymentRepository.findByClubIdAndUserId(clubId, userId) - .orElseThrow(() -> CustomException.of(NOT_FOUND_FEE_PAYMENT)); - return ClubFeePaymentResponse.from(payment); - } -} From 629f3b864480dcbdb4208b59600a78a40d0e884a Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Wed, 18 Mar 2026 11:57:56 +0900 Subject: [PATCH 27/33] =?UTF-8?q?fix:=20ClubSheetSortKey=EC=97=90=EC=84=9C?= =?UTF-8?q?=20FEE=5FPAID=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../konect/domain/club/controller/ClubMemberSheetApi.java | 6 +++--- .../gg/agit/konect/domain/club/enums/ClubSheetSortKey.java | 3 +-- .../agit/konect/domain/club/service/SheetSyncExecutor.java | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java index f8e0463c..29ebaf24 100644 --- a/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -36,10 +36,10 @@ ResponseEntity updateSheetId( @Operation( summary = "동아리 인명부 스프레드시트 동기화", - description = "등록된 구글 스프레드시트에 동아리 회원 인명부와 회비 납부 현황을 동기화합니다. " - + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT, FEE_PAID)을 지정할 수 있으며, " + description = "등록된 구글 스프레드시트에 동아리 회원 인명부를 동기화합니다. " + + "sortKey로 정렬 기준(NAME, STUDENT_ID, POSITION, JOINED_AT)을 지정할 수 있으며, " + "ascending으로 오름차순/내림차순을 설정합니다. " - + "가입 승인·탈퇴·회비 납부 승인 시에도 자동으로 동기화됩니다." + + "가입 승인·탈퇴 시에도 자동으로 동기화됩니다." ) @PostMapping("/{clubId}/members/sheet-sync") ResponseEntity syncMembersToSheet( diff --git a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java index d859affa..06f9c43e 100644 --- a/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java +++ b/src/main/java/gg/agit/konect/domain/club/enums/ClubSheetSortKey.java @@ -4,6 +4,5 @@ public enum ClubSheetSortKey { NAME, STUDENT_ID, POSITION, - JOINED_AT, - FEE_PAID + JOINED_AT } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index ce8be8b2..636d1e65 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -247,7 +247,7 @@ private List sort( case STUDENT_ID -> Comparator.comparing(m -> m.getUser().getStudentNumber()); case POSITION -> Comparator.comparingInt(m -> m.getClubPosition().getPriority()); case JOINED_AT -> Comparator.comparing(ClubMember::getCreatedAt); - case FEE_PAID -> Comparator.comparing(m -> m.getClubPosition().getPriority()); + }; if (!ascending) { From 779c815729fc9bd8c7fb62b891d21e11e08aa5ca Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 16:59:03 +0900 Subject: [PATCH 28/33] =?UTF-8?q?fix:=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=8B=9C=20=ED=83=88=ED=87=B4=20=EB=A9=A4=EB=B2=84=20=ED=96=89?= =?UTF-8?q?=EC=9D=B4=20=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=9E=94=EC=A1=B4=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/service/SheetSyncExecutor.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 636d1e65..c2629a5d 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.BasicFilter; +import com.google.api.services.sheets.v4.model.BatchClearValuesRequest; import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest; import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; import com.google.api.services.sheets.v4.model.ClearValuesRequest; @@ -125,6 +126,7 @@ private void updateMappedColumns( SheetColumnMapping mapping ) throws IOException { int dataStartRow = mapping.getDataStartRow(); + clearMappedColumns(spreadsheetId, mapping, dataStartRow); Map> columnData = buildColumnData(members, mapping); List data = new ArrayList<>(); @@ -147,6 +149,29 @@ private void updateMappedColumns( } } + private void clearMappedColumns( + String spreadsheetId, + SheetColumnMapping mapping, + int dataStartRow + ) throws IOException { + List clearRanges = new ArrayList<>(); + for (String field : List.of( + SheetColumnMapping.NAME, SheetColumnMapping.STUDENT_ID, SheetColumnMapping.EMAIL, + SheetColumnMapping.PHONE, SheetColumnMapping.POSITION, SheetColumnMapping.JOINED_AT + )) { + int colIndex = mapping.getColumnIndex(field); + if (colIndex >= 0) { + String colLetter = columnLetter(colIndex); + clearRanges.add(colLetter + dataStartRow + ":" + colLetter); + } + } + if (!clearRanges.isEmpty()) { + googleSheetsService.spreadsheets().values() + .batchClear(spreadsheetId, new BatchClearValuesRequest().setRanges(clearRanges)) + .execute(); + } + } + private Map> buildColumnData( List members, SheetColumnMapping mapping From d99aae390ec8e8a5262c2efb63839ea0be660d30 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 17:08:43 +0900 Subject: [PATCH 29/33] =?UTF-8?q?feat:=20=EC=8B=9C=ED=8A=B8=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9E=90=EC=97=90=EA=B2=8C=20=EC=8A=A4?= =?UTF-8?q?=ED=94=84=EB=A0=88=EB=93=9C=EC=8B=9C=ED=8A=B8=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/service/SheetMigrationService.java | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java index 0b2ea2b2..338e69ef 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetMigrationService.java @@ -17,9 +17,13 @@ import com.google.api.services.sheets.v4.Sheets; import com.google.api.services.sheets.v4.model.ValueRange; +import com.google.api.services.drive.model.Permission; + import gg.agit.konect.domain.club.model.Club; import gg.agit.konect.domain.club.model.SheetColumnMapping; 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.exception.CustomException; import gg.agit.konect.global.code.ApiResponseCode; import lombok.RequiredArgsConstructor; @@ -45,6 +49,7 @@ public class SheetMigrationService { private final Sheets googleSheetsService; private final SheetHeaderMapper sheetHeaderMapper; private final ClubRepository clubRepository; + private final UserRepository userRepository; @Transactional public String migrateToTemplate( @@ -53,6 +58,7 @@ public String migrateToTemplate( String sourceSpreadsheetUrl ) { Club club = clubRepository.getById(clubId); + User requester = userRepository.getById(requesterId); String templateId = defaultTemplateSpreadsheetId; if (templateId == null || templateId.isBlank()) { @@ -62,7 +68,7 @@ public String migrateToTemplate( String sourceSpreadsheetId = extractSpreadsheetId(sourceSpreadsheetUrl); String folderId = resolveFolderId(sourceSpreadsheetUrl, sourceSpreadsheetId); - String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId); + String newSpreadsheetId = copyTemplate(templateId, club.getName(), folderId, requester.getEmail()); SheetHeaderMapper.SheetAnalysisResult sourceAnalysis = sheetHeaderMapper.analyzeAllSheets(sourceSpreadsheetId); @@ -126,7 +132,7 @@ private String resolveFolderId(String url, String spreadsheetId) { return null; } - private String copyTemplate(String templateId, String clubName, String targetFolderId) { + private String copyTemplate(String templateId, String clubName, String targetFolderId, String ownerEmail) { try { String title = NEW_SHEET_TITLE_PREFIX + clubName; File copyMetadata = new File().setName(title); @@ -140,6 +146,9 @@ private String copyTemplate(String templateId, String clubName, String targetFol .execute(); log.info("Template copied. newId={}, folderId={}", copied.getId(), targetFolderId); + + transferOwnership(copied.getId(), ownerEmail); + return copied.getId(); } catch (IOException e) { @@ -148,6 +157,23 @@ private String copyTemplate(String templateId, String clubName, String targetFol } } + private void transferOwnership(String fileId, String ownerEmail) { + try { + Permission permission = new Permission() + .setType("user") + .setRole("owner") + .setEmailAddress(ownerEmail); + + googleDriveService.permissions().create(fileId, permission) + .setTransferOwnership(true) + .execute(); + + log.info("Ownership transferred. fileId={}, ownerEmail={}", fileId, ownerEmail); + } catch (IOException e) { + log.warn("Failed to transfer ownership. fileId={}, cause={}", fileId, e.getMessage()); + } + } + private List> readAllData( String spreadsheetId, SheetColumnMapping mapping From 5fc337f56da185ab5173a2f8e8df1693307e6181 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 17:08:48 +0900 Subject: [PATCH 30/33] =?UTF-8?q?chore:=20ClubFeePayment=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=A1=A4=EB=B0=B1=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migration/V55__rollback_fee_payment_feature.sql | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql index f4043644..f7e84684 100644 --- a/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql +++ b/src/main/resources/db/migration/V55__rollback_fee_payment_feature.sql @@ -3,5 +3,7 @@ DROP TABLE IF EXISTS club_fee_payment; -- V53 rollback: drop fee_sheet columns from club ALTER TABLE club - DROP COLUMN IF EXISTS fee_sheet_id, - DROP COLUMN IF EXISTS fee_sheet_column_mapping; + DROP COLUMN fee_sheet_id; + +ALTER TABLE club + DROP COLUMN fee_sheet_column_mapping; From 57ccf08dbae4edb33f71d80b28c207424f1c5771 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 17:19:33 +0900 Subject: [PATCH 31/33] =?UTF-8?q?refactor:=20AI=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EC=96=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../club/dto/ClubSheetIdUpdateRequest.java | 5 ++++ .../service/ClubMemberManagementService.java | 5 ++++ .../club/service/ClubMemberSheetService.java | 26 +++++++++++++------ .../club/service/SheetHeaderMapper.java | 8 +++++- .../club/service/SheetSyncDebouncer.java | 22 +++++++--------- .../club/service/SheetSyncExecutor.java | 4 +-- .../konect/global/config/AsyncConfig.java | 5 ++++ .../googlesheets/GoogleSheetsConfig.java | 13 +++++----- 8 files changed, 58 insertions(+), 30 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java index 339a9433..51a34e92 100644 --- a/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java +++ b/src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java @@ -2,9 +2,14 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; public record ClubSheetIdUpdateRequest( @NotBlank(message = "스프레드시트 ID는 필수 입력입니다.") + @Pattern( + regexp = "^[A-Za-z0-9_-]+$", + message = "스프레드시트 ID는 영문자, 숫자, 하이픈(-), 언더스코어(_)만 허용합니다." + ) @Schema( description = "등록할 구글 스프레드시트 ID (URL의 /d/{spreadsheetId}/ 부분)", example = "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java index 9b1a6ed9..7c973d13 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberManagementService.java @@ -75,6 +75,7 @@ public ClubMember changeMemberPosition( validatePositionLimit(clubId, newPosition, target); target.changePosition(newPosition); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return target; } @@ -133,6 +134,7 @@ private ClubPreMemberAddResponse addDirectMember(Club club, User user, ClubPosit ClubMember savedMember = clubMemberRepository.save(clubMember); chatRoomMembershipService.addClubMember(savedMember); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(club.getId())); return ClubPreMemberAddResponse.from(savedMember); } @@ -197,6 +199,7 @@ public List transferPresident( currentPresident.changePosition(MEMBER); newPresident.changePosition(PRESIDENT); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return List.of(currentPresident, newPresident); } @@ -226,6 +229,7 @@ public List changeVicePresident( ClubMember currentVicePresident = currentVicePresidentOpt.get(); currentVicePresident.changePosition(MEMBER); changedMembers.add(currentVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); } return changedMembers; } @@ -244,6 +248,7 @@ public List changeVicePresident( newVicePresident.changePosition(VICE_PRESIDENT); changedMembers.add(newVicePresident); + applicationEventPublisher.publishEvent(ClubMemberChangedEvent.of(clubId)); return changedMembers; } diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index a6403091..c228cffe 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -39,25 +39,35 @@ public void onClubMemberChanged(ClubMemberChangedEvent event) { sheetSyncDebouncer.debounce(event.clubId()); } - @Transactional public void updateSheetId( Integer clubId, Integer requesterId, ClubSheetIdUpdateRequest request ) { - Club club = clubRepository.getById(clubId); - clubPermissionValidator.validateManagerAccess(clubId, requesterId); - club.updateGoogleSheetId(request.spreadsheetId()); - SheetHeaderMapper.SheetAnalysisResult result = sheetHeaderMapper.analyzeAllSheets(request.spreadsheetId()); + String mappingJson = null; try { - club.updateSheetColumnMapping( - objectMapper.writeValueAsString(result.memberListMapping().toMap()) - ); + mappingJson = objectMapper.writeValueAsString(result.memberListMapping().toMap()); } catch (JsonProcessingException e) { log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } + persistSheetId(clubId, requesterId, request.spreadsheetId(), mappingJson); + } + + @Transactional + protected void persistSheetId( + Integer clubId, + Integer requesterId, + String spreadsheetId, + String mappingJson + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + club.updateGoogleSheetId(spreadsheetId); + if (mappingJson != null) { + club.updateSheetColumnMapping(mappingJson); + } } @Transactional(readOnly = true) diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java index 435f1902..e210f613 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -28,6 +28,8 @@ public class SheetHeaderMapper { private static final String API_URL = "https://api.anthropic.com/v1/messages"; private static final String ANTHROPIC_VERSION = "2023-06-01"; + // 헤더 분석용으로 haiku 모델을 고정 사용 (ClaudeProperties.model()과 의도적으로 분리) + // 운영 모델과 다르게 비용/속도 최적화를 위해 저비용 모델을 선택 private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; private static final int MAX_TOKENS = 1024; private static final int SCAN_ROWS = 10; @@ -199,7 +201,11 @@ private String callClaude(String prompt) { .body(String.class); JsonNode root = objectMapper.readTree(response); - return root.path("content").get(0).path("text").asText(); + JsonNode content = root.path("content"); + if (!content.isArray() || content.isEmpty()) { + throw new RuntimeException("Claude API returned empty content. response=" + response); + } + return content.get(0).path("text").asText(); } catch (RestClientException | IOException e) { throw new RuntimeException("Claude API call failed", e); diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java index e954c04a..c6b7b933 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java @@ -26,17 +26,15 @@ public class SheetSyncDebouncer { private final SheetSyncExecutor sheetSyncExecutor; public void debounce(Integer clubId) { - ScheduledFuture existing = pendingTasks.get(clubId); - if (existing != null && !existing.isDone()) { - existing.cancel(false); - log.debug("Sheet sync debounced. clubId={}", clubId); - } - - ScheduledFuture future = scheduler.schedule(() -> { - pendingTasks.remove(clubId); - sheetSyncExecutor.execute(clubId); - }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); - - pendingTasks.put(clubId, future); + pendingTasks.compute(clubId, (id, existing) -> { + if (existing != null && !existing.isDone()) { + existing.cancel(false); + log.debug("Sheet sync debounced. clubId={}", id); + } + return scheduler.schedule(() -> { + pendingTasks.remove(id); + sheetSyncExecutor.execute(id); + }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); + }); } } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index c2629a5d..8ef17127 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -62,7 +62,6 @@ public void execute(Integer clubId) { executeWithSort(clubId, ClubSheetSortKey.POSITION, true); } - @Async("sheetSyncTaskExecutor") @Transactional(readOnly = true) public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { Club club = clubRepository.getById(clubId); @@ -82,14 +81,13 @@ public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean as clearAndWriteAll(spreadsheetId, sorted); applyFormat(spreadsheetId); } + log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } catch (IOException e) { log.error( "Sheet sync failed. clubId={}, spreadsheetId={}, cause={}", clubId, spreadsheetId, e.getMessage(), e ); } - - log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); } private SheetColumnMapping resolveRawMapping(String mappingJson) { diff --git a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java index beb94d66..bfc63895 100644 --- a/src/main/java/gg/agit/konect/global/config/AsyncConfig.java +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -1,6 +1,7 @@ package gg.agit.konect.global.config; import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +13,7 @@ public class AsyncConfig { private static final int SHEET_SYNC_CORE_POOL_SIZE = 2; private static final int SHEET_SYNC_MAX_POOL_SIZE = 4; private static final int SHEET_SYNC_QUEUE_CAPACITY = 50; + private static final int SHEET_SYNC_AWAIT_TERMINATION_SECONDS = 30; @Bean(name = "sheetSyncTaskExecutor") public Executor sheetSyncTaskExecutor() { @@ -20,6 +22,9 @@ public Executor sheetSyncTaskExecutor() { executor.setMaxPoolSize(SHEET_SYNC_MAX_POOL_SIZE); executor.setQueueCapacity(SHEET_SYNC_QUEUE_CAPACITY); executor.setThreadNamePrefix("sheet-sync-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(SHEET_SYNC_AWAIT_TERMINATION_SECONDS); executor.initialize(); return executor; } diff --git a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java index 3e6b4eb7..8467c3c9 100644 --- a/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java +++ b/src/main/java/gg/agit/konect/infrastructure/googlesheets/GoogleSheetsConfig.java @@ -28,12 +28,13 @@ public class GoogleSheetsConfig { @Bean public GoogleCredentials googleCredentials() throws IOException { - InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath()); - return GoogleCredentials.fromStream(in) - .createScoped(Arrays.asList( - SheetsScopes.SPREADSHEETS, - DriveScopes.DRIVE - )); + try (InputStream in = new FileInputStream(googleSheetsProperties.credentialsPath())) { + return GoogleCredentials.fromStream(in) + .createScoped(Arrays.asList( + SheetsScopes.SPREADSHEETS, + DriveScopes.DRIVE + )); + } } @Bean From 94aa15b380b4c8e055445530f028c190a99ee62e Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 17:30:03 +0900 Subject: [PATCH 32/33] =?UTF-8?q?fix:=20@Transactional=20self-invocation?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/club/service/ClubMemberSheetService.java | 12 ++---------- .../domain/club/service/SheetSyncDebouncer.java | 3 ++- .../domain/club/service/SheetSyncExecutor.java | 5 ----- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java index c228cffe..6485f8ae 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -39,6 +39,7 @@ public void onClubMemberChanged(ClubMemberChangedEvent event) { sheetSyncDebouncer.debounce(event.clubId()); } + @Transactional public void updateSheetId( Integer clubId, Integer requesterId, @@ -52,19 +53,10 @@ public void updateSheetId( } catch (JsonProcessingException e) { log.warn("Failed to serialize mapping, skipping. cause={}", e.getMessage()); } - persistSheetId(clubId, requesterId, request.spreadsheetId(), mappingJson); - } - @Transactional - protected void persistSheetId( - Integer clubId, - Integer requesterId, - String spreadsheetId, - String mappingJson - ) { Club club = clubRepository.getById(clubId); clubPermissionValidator.validateManagerAccess(clubId, requesterId); - club.updateGoogleSheetId(spreadsheetId); + club.updateGoogleSheetId(request.spreadsheetId()); if (mappingJson != null) { club.updateSheetColumnMapping(mappingJson); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java index c6b7b933..9f84b3d0 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncDebouncer.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -33,7 +34,7 @@ public void debounce(Integer clubId) { } return scheduler.schedule(() -> { pendingTasks.remove(id); - sheetSyncExecutor.execute(id); + sheetSyncExecutor.executeWithSort(id, ClubSheetSortKey.POSITION, true); }, DEBOUNCE_DELAY_SECONDS, TimeUnit.SECONDS); }); } diff --git a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java index 8ef17127..a6eea1fc 100644 --- a/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -57,11 +57,6 @@ public class SheetSyncExecutor { private final ObjectMapper objectMapper; @Async("sheetSyncTaskExecutor") - @Transactional(readOnly = true) - public void execute(Integer clubId) { - executeWithSort(clubId, ClubSheetSortKey.POSITION, true); - } - @Transactional(readOnly = true) public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { Club club = clubRepository.getById(clubId); From 3bb784bf65013874cb881be0d0804afae136b108 Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Fri, 20 Mar 2026 17:45:58 +0900 Subject: [PATCH 33/33] =?UTF-8?q?fix:=20NOT=5FFOUND=5FADVERTISEMENT=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B3=B5=EA=B5=AC=20-=20develop=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/gg/agit/konect/global/code/ApiResponseCode.java | 1 + 1 file changed, 1 insertion(+) 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 33359c58..682a9749 100644 --- a/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java +++ b/src/main/java/gg/agit/konect/global/code/ApiResponseCode.java @@ -91,6 +91,7 @@ public enum ApiResponseCode { NOT_FOUND_BANK(HttpStatus.NOT_FOUND, "해당하는 은행을 찾을 수 없습니다."), NOT_FOUND_VERSION(HttpStatus.NOT_FOUND, "버전을 찾을 수 없습니다."), NOT_FOUND_NOTIFICATION_TOKEN(HttpStatus.NOT_FOUND, "알림 토큰을 찾을 수 없습니다."), + NOT_FOUND_ADVERTISEMENT(HttpStatus.NOT_FOUND, "광고를 찾을 수 없습니다."), NOT_FOUND_CLUB_SHEET_ID(HttpStatus.NOT_FOUND, "등록된 스프레드시트 ID가 없습니다. 먼저 스프레드시트 ID를 등록해 주세요."), // 405 Method Not Allowed (지원하지 않는 HTTP 메소드)