From 7d47e18906ece8a959f1841e4f866e873c9f9fac Mon Sep 17 00:00:00 2001 From: JanooGwan Date: Tue, 17 Mar 2026 00:47:48 +0900 Subject: [PATCH 01/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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/18] =?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,