diff --git a/build.gradle b/build.gradle index d854c5a0..72fcf961 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-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) // test 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..79a11181 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java @@ -0,0 +1,64 @@ +package gg.agit.konect.domain.club.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +import gg.agit.konect.domain.club.dto.ClubFeePaymentResponse; +import gg.agit.konect.domain.club.dto.ClubFeePaymentSubmitRequest; +import gg.agit.konect.global.auth.annotation.UserId; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "(Normal) Club - FeePayment") +@RequestMapping("/clubs") +public interface ClubFeePaymentApi { + + @Operation( + summary = "회비 납부 접수", + description = "회원이 회비를 납부했음을 접수합니다. 납부 증빙 이미지 URL을 함께 제출할 수 있습니다." + ) + @PostMapping("/{clubId}/fee-payments") + ResponseEntity submitFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @RequestBody ClubFeePaymentSubmitRequest request, + @UserId Integer requesterId + ); + + @Operation( + summary = "회비 납부 승인 (운영진 전용)", + description = "운영진이 특정 회원의 회비 납부를 승인합니다. " + + "승인 즉시 구글 스프레드시트의 해당 회원 납부 여부(FeePaid) 컬럼이 자동으로 업데이트됩니다." + ) + @PostMapping("/{clubId}/fee-payments/{targetUserId}/approve") + ResponseEntity approveFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @PathVariable(name = "targetUserId") Integer targetUserId, + @UserId Integer requesterId + ); + + @Operation( + summary = "전체 회비 납부 목록 조회 (운영진 전용)", + description = "동아리 전체 회원의 회비 납부 현황을 조회합니다. 납부 여부, 납부일, 증빙 이미지 URL을 확인할 수 있습니다." + ) + @GetMapping("/{clubId}/fee-payments") + ResponseEntity> getFeePayments( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ); + + @Operation( + summary = "내 회비 납부 상태 조회", + description = "로그인한 회원 본인의 회비 납부 상태를 조회합니다." + ) + @GetMapping("/{clubId}/fee-payments/me") + ResponseEntity getMyFeePayment( + @PathVariable(name = "clubId") Integer clubId, + @UserId Integer requesterId + ); +} diff --git a/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java b/src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentController.java 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 new file mode 100644 index 00000000..f8e0463c --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetApi.java @@ -0,0 +1,51 @@ +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.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; +import jakarta.validation.Valid; + +@Tag(name = "(Normal) Club - Sheet") +@RequestMapping("/clubs") +public interface ClubMemberSheetApi { + + @Operation( + summary = "구글 스프레드시트 ID 등록 / 수정", + description = "동아리에서 사용 중인 구글 스프레드시트 ID를 등록하거나 수정합니다. " + + "등록 시 AI(Claude Haiku)가 시트 상단 10행을 자동으로 분석하여 " + + "이름·학번·연락처 등 컬럼 위치를 파악하고, 이후 동기화 시 해당 컬럼에만 값을 채웁니다. " + + "시트 양식이 변경된 경우 이 API를 다시 호출하면 AI가 재분석합니다." + ) + @PutMapping("/{clubId}/sheet") + ResponseEntity updateSheetId( + @PathVariable(name = "clubId") Integer clubId, + @Valid @RequestBody ClubSheetIdUpdateRequest request, + @UserId Integer requesterId + ); + + @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, + @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 new file mode 100644 index 00000000..ad374cc8 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/controller/ClubMemberSheetController.java @@ -0,0 +1,46 @@ +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.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; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/clubs") +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, + @RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey, + @RequestParam(name = "ascending", defaultValue = "true") boolean ascending, + @UserId Integer 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/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/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/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/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/model/Club.java b/src/main/java/gg/agit/konect/domain/club/model/Club.java index 3e946392..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 @@ -84,6 +84,12 @@ public class Club extends BaseEntity { @Column(name = "is_application_enabled") private Boolean isApplicationEnabled; + @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; @@ -224,4 +230,12 @@ private void clearFeeInfo() { this.feeAccountNumber = null; this.feeAccountHolder = null; } + + 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/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/model/SheetColumnMapping.java b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java new file mode 100644 index 00000000..600cfde2 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/model/SheetColumnMapping.java @@ -0,0 +1,69 @@ +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 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 static final int DEFAULT_DATA_START_ROW = 2; + + private final Map fieldToColumn; + private final int dataStartRow; + + 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() { + Map mapping = new HashMap<>(); + 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, DEFAULT_DATA_START_ROW); + } + + public boolean hasColumn(String field) { + return fieldToColumn.containsKey(field); + } + + public int getColumnIndex(String field) { + return fieldToColumn.getOrDefault(field, -1); + } + + 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/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/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/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/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 new file mode 100644 index 00000000..17b91439 --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/ClubMemberSheetService.java @@ -0,0 +1,89 @@ +package gg.agit.konect.domain.club.service; + +import static gg.agit.konect.global.code.ApiResponseCode.NOT_FOUND_CLUB_SHEET_ID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +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; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClubMemberSheetService { + + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + 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) { + sheetSyncDebouncer.debounce(event.clubId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onClubFeePaymentApproved(ClubFeePaymentApprovedEvent event) { + sheetSyncDebouncer.debounce(event.clubId()); + } + + @Transactional + public void updateSheetId( + Integer clubId, + Integer requesterId, + ClubSheetIdUpdateRequest request + ) { + Club club = clubRepository.getById(clubId); + clubPermissionValidator.validateManagerAccess(clubId, requesterId); + club.updateGoogleSheetId(request.spreadsheetId()); + + 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()); + } + } + + @Transactional(readOnly = true) + public ClubMemberSheetSyncResponse syncMembersToSheet( + Integer clubId, + Integer requesterId, + ClubSheetSortKey sortKey, + boolean ascending + ) { + 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); + } + + long memberCount = clubMemberRepository.countByClubId(clubId); + sheetSyncExecutor.executeWithSort(clubId, sortKey, ascending); + + return ClubMemberSheetSyncResponse.of((int)memberCount, spreadsheetId); + } +} 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..509296ff --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetHeaderMapper.java @@ -0,0 +1,195 @@ +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; + +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.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class SheetHeaderMapper { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + private static final String MAPPING_MODEL = "claude-haiku-4-5-20251001"; + private static final int MAX_TOKENS = 512; + private static final int SCAN_ROWS = 10; + private static final String SCAN_RANGE = "A1:Z10"; + + private final Sheets googleSheetsService; + private final ClaudeProperties claudeProperties; + 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> rows = readRows(spreadsheetId); + if (rows.isEmpty()) { + log.warn("No data found in spreadsheet. Using default mapping."); + return SheetColumnMapping.defaultMapping(); + } + + try { + return inferMapping(rows); + } catch (Exception e) { + log.warn( + "Header analysis failed, using default mapping. cause={}", + e.getMessage() + ); + return SheetColumnMapping.defaultMapping(); + } + } + + private List> readRows(String spreadsheetId) { + try { + ValueRange response = googleSheetsService.spreadsheets().values() + .get(spreadsheetId, SCAN_RANGE) + .execute(); + + List> values = response.getValues(); + if (values == null || values.isEmpty()) { + return List.of(); + } + + 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 rows. spreadsheetId={}", spreadsheetId, e); + return List.of(); + } + } + + private SheetColumnMapping inferMapping(List> rows) throws Exception { + String prompt = buildPrompt(rows); + String rawJson = callClaude(prompt); + return parseMapping(rawJson); + } + + 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(""" + Below are the first rows of a spreadsheet used by a Korean university club: + + %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 (이름, 성명 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) { + 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) { + 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 root = objectMapper.readTree(cleaned); + int headerRow = root.path("headerRow").asInt(1); + int dataStartRow = headerRow + 1; + + JsonNode mappingNode = root.path("mapping"); + Map mapping = new HashMap<>(); + + mappingNode.fields().forEachRemaining(entry -> { + int colIndex = entry.getValue().asInt(-1); + if (colIndex >= 0) { + mapping.put(entry.getKey(), colIndex); + } + }); + + 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); + return SheetColumnMapping.defaultMapping(); + } + } +} 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..eb9f392a --- /dev/null +++ b/src/main/java/gg/agit/konect/domain/club/service/SheetSyncExecutor.java @@ -0,0 +1,297 @@ +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +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; +import com.google.api.services.sheets.v4.model.ClearValuesRequest; +import com.google.api.services.sheets.v4.model.GridProperties; +import com.google.api.services.sheets.v4.model.GridRange; +import com.google.api.services.sheets.v4.model.Request; +import com.google.api.services.sheets.v4.model.SetBasicFilterRequest; +import com.google.api.services.sheets.v4.model.SheetProperties; +import com.google.api.services.sheets.v4.model.BatchUpdateValuesRequest; +import com.google.api.services.sheets.v4.model.UpdateSheetPropertiesRequest; +import com.google.api.services.sheets.v4.model.ValueRange; + +import gg.agit.konect.domain.club.enums.ClubSheetSortKey; +import gg.agit.konect.domain.club.model.Club; +import gg.agit.konect.domain.club.model.ClubFeePayment; +import gg.agit.konect.domain.club.model.ClubMember; +import gg.agit.konect.domain.club.model.SheetColumnMapping; +import gg.agit.konect.domain.club.repository.ClubFeePaymentRepository; +import gg.agit.konect.domain.club.repository.ClubMemberRepository; +import gg.agit.konect.domain.club.repository.ClubRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +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"); + + private final Sheets googleSheetsService; + private final ClubRepository clubRepository; + private final ClubMemberRepository clubMemberRepository; + private final ClubFeePaymentRepository clubFeePaymentRepository; + private final ObjectMapper objectMapper; + + @Async("sheetSyncTaskExecutor") + @Transactional(readOnly = true) + public void execute(Integer clubId) { + executeWithSort(clubId, ClubSheetSortKey.POSITION, true); + } + + @Async("sheetSyncTaskExecutor") + @Transactional(readOnly = true) + public void executeWithSort(Integer clubId, ClubSheetSortKey sortKey, boolean ascending) { + Club club = clubRepository.getById(clubId); + String spreadsheetId = club.getGoogleSheetId(); + if (spreadsheetId == null || spreadsheetId.isBlank()) { + return; + } + + SheetColumnMapping mapping = resolveMapping(club); + List members = clubMemberRepository.findAllByClubId(clubId); + List payments = clubFeePaymentRepository.findAllByClubId(clubId); + + Map paymentMap = payments.stream() + .collect(Collectors.toMap(p -> p.getUser().getId(), p -> p)); + + List sorted = sort(members, paymentMap, sortKey, ascending); + + try { + 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={}", + clubId, spreadsheetId, e.getMessage(), e + ); + } + + log.info("Sheet sync done. clubId={}, members={}", clubId, members.size()); + } + + private SheetColumnMapping resolveMapping(Club club) { + String mappingJson = club.getSheetColumnMapping(); + if (mappingJson == null || mappingJson.isBlank()) { + return SheetColumnMapping.defaultMapping(); + } + try { + Map raw = objectMapper.readValue( + mappingJson, new TypeReference<>() {} + ); + int dataStartRow = raw.containsKey("dataStartRow") + ? ((Number)raw.get("dataStartRow")).intValue() : 2; + Map fieldMap = new HashMap<>(); + raw.forEach((key, value) -> { + if (!"dataStartRow".equals(key) && value instanceof Number 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(); + } + } + + private void updateMappedColumns( + String spreadsheetId, + List members, + Map paymentMap, + SheetColumnMapping mapping + ) throws IOException { + int dataStartRow = mapping.getDataStartRow(); + Map> columnData = buildColumnData(members, paymentMap, mapping); + + List data = new ArrayList<>(); + for (Map.Entry> entry : columnData.entrySet()) { + int colIndex = entry.getKey(); + 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 + ) { + Map> columns = new HashMap<>(); + + for (ClubMember member : members) { + Integer userId = member.getUser().getId(); + ClubFeePayment payment = paymentMap.get(userId); + + putValue(columns, mapping, SheetColumnMapping.NAME, + member.getUser().getName()); + putValue(columns, mapping, SheetColumnMapping.STUDENT_ID, + 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 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 clearAndWriteAll( + String spreadsheetId, + List members, + Map paymentMap + ) throws IOException { + String clearRange = "A:H"; + googleSheetsService.spreadsheets().values() + .clear(spreadsheetId, clearRange, new ClearValuesRequest()) + .execute(); + + List headerRow = List.of( + "Name", "StudentId", "Email", "Phone", "Position", "JoinedAt", "FeePaid", "PaidAt" + ); + List> rows = new ArrayList<>(); + rows.add(headerRow); + + 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) + .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 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 String columnLetter(int index) { + StringBuilder sb = new StringBuilder(); + index++; + while (index > 0) { + index--; + sb.insert(0, (char)('A' + index % ALPHABET_SIZE)); + index /= ALPHABET_SIZE; + } + return sb.toString(); + } +} 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..5ad1ca83 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,10 @@ 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를 등록해 주세요."), + 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 메소드 입니다."), @@ -115,6 +119,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/global/config/AsyncConfig.java b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java new file mode 100644 index 00000000..beb94d66 --- /dev/null +++ b/src/main/java/gg/agit/konect/global/config/AsyncConfig.java @@ -0,0 +1,26 @@ +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 { + + 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 = "sheetSyncTaskExecutor") + public Executor sheetSyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + 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; + } +} 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} 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; 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) +); 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;