Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7d47e18
feat: 구글 스프레드시트 동아리 인명부 동기화 API 구현
JanooGwan Mar 16, 2026
75e88e0
feat: 구글 스프레드시트 동아리 인명부 동기화 API 구현
JanooGwan Mar 16, 2026
d51b6ff
fix: 전화번호 앞 0 누락 방지 및 동기화 실패 로그 추가
JanooGwan Mar 16, 2026
3a93a1a
feat: 동아리별 스프레드시트 ID 저장 및 sync API 개선
JanooGwan Mar 17, 2026
3207516
fix: Checkstyle 120자 초과 라인 수정
JanooGwan Mar 17, 2026
d03fa98
fix: Checkstyle 120자 초과 해결 - Swagger description 영어 변환
JanooGwan Mar 17, 2026
6535eee
feat: 가입 승인/탈퇴 시 구글 시트 자동 동기화 트리거
JanooGwan Mar 17, 2026
b0604ca
feat: 회비 납부 장부 자동화 - Async/Debounce 시트 동기화 및 정렬 기능 구현
JanooGwan Mar 17, 2026
60a90e9
fix: Checkstyle - MagicNumber 상수 치환 및 NoWhitespaceAfter 수정
JanooGwan Mar 17, 2026
5b78723
feat: Claude API 기반 시트 헤더 자동 분석 및 커스텀 매핑 동기화 구현
JanooGwan Mar 17, 2026
916cc61
fix: Checkstyle MagicNumber 상수 치환
JanooGwan Mar 17, 2026
bcf5c3c
fix: sheetSyncExecutor Bean 이름 충돌 해결 - sheetSyncTaskExecutor로 변경
JanooGwan Mar 17, 2026
ae151f3
fix: SheetHeaderMapper RestClient.Builder로 교체 (Bean 미등록 오류 해결)
JanooGwan Mar 17, 2026
d453f38
fix: @TransactionalEventListener + @Transactional 충돌 해결 - 클래스 레벨 트랜잭션 제거
JanooGwan Mar 17, 2026
d0ddd96
feat: 시트 헤더 위치 자동 감지 - 상위 10행 스캔 및 dataStartRow 기반 동기화 개선
JanooGwan Mar 17, 2026
23fc56b
fix: Checkstyle NoWhitespaceAfter 수정
JanooGwan Mar 17, 2026
cef5033
docs: ClubMemberSheetApi Swagger 설명 한글화
JanooGwan Mar 17, 2026
a0477d8
docs: ClubFeePaymentApi Swagger 설명 한글화
JanooGwan Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClubFeePaymentResponse> submitFeePayment(
@PathVariable(name = "clubId") Integer clubId,
@RequestBody ClubFeePaymentSubmitRequest request,
@UserId Integer requesterId
);
Comment on lines +27 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

[LEVEL: medium] @Valid 어노테이션 누락

ClubFeePaymentSubmitRequest에 Bean Validation 제약조건이 있을 경우 검증이 수행되지 않습니다. 동일 PR의 ClubMemberSheetApi에서는 @Valid를 사용하고 있어 일관성이 부족합니다. @Valid @RequestBody``로 수정을 권장합니다.

수정 제안
     ResponseEntity<ClubFeePaymentResponse> submitFeePayment(
         `@PathVariable`(name = "clubId") Integer clubId,
-        `@RequestBody` ClubFeePaymentSubmitRequest request,
+        `@Valid` `@RequestBody` ClubFeePaymentSubmitRequest request,
         `@UserId` Integer requesterId
     );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ResponseEntity<ClubFeePaymentResponse> submitFeePayment(
@PathVariable(name = "clubId") Integer clubId,
@RequestBody ClubFeePaymentSubmitRequest request,
@UserId Integer requesterId
);
ResponseEntity<ClubFeePaymentResponse> submitFeePayment(
`@PathVariable`(name = "clubId") Integer clubId,
`@Valid` `@RequestBody` ClubFeePaymentSubmitRequest request,
`@UserId` Integer requesterId
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/gg/agit/konect/domain/club/controller/ClubFeePaymentApi.java`
around lines 24 - 28, Add Bean Validation to the submitFeePayment endpoint by
annotating the request parameter with `@Valid` so ClubFeePaymentSubmitRequest
constraints are enforced; update the method signature in ClubFeePaymentApi for
submitFeePayment to use `@Valid` on the `@RequestBody` parameter (mirroring the
usage in ClubMemberSheetApi) to ensure validation is performed before
processing.


@Operation(
summary = "회비 납부 승인 (운영진 전용)",
description = "운영진이 특정 회원의 회비 납부를 승인합니다. "
+ "승인 즉시 구글 스프레드시트의 해당 회원 납부 여부(FeePaid) 컬럼이 자동으로 업데이트됩니다."
)
@PostMapping("/{clubId}/fee-payments/{targetUserId}/approve")
ResponseEntity<ClubFeePaymentResponse> approveFeePayment(
@PathVariable(name = "clubId") Integer clubId,
@PathVariable(name = "targetUserId") Integer targetUserId,
@UserId Integer requesterId
);

@Operation(
summary = "전체 회비 납부 목록 조회 (운영진 전용)",
description = "동아리 전체 회원의 회비 납부 현황을 조회합니다. 납부 여부, 납부일, 증빙 이미지 URL을 확인할 수 있습니다."
)
@GetMapping("/{clubId}/fee-payments")
ResponseEntity<List<ClubFeePaymentResponse>> getFeePayments(
@PathVariable(name = "clubId") Integer clubId,
@UserId Integer requesterId
);

@Operation(
summary = "내 회비 납부 상태 조회",
description = "로그인한 회원 본인의 회비 납부 상태를 조회합니다."
)
@GetMapping("/{clubId}/fee-payments/me")
ResponseEntity<ClubFeePaymentResponse> getMyFeePayment(
@PathVariable(name = "clubId") Integer clubId,
@UserId Integer requesterId
);
}
Original file line number Diff line number Diff line change
@@ -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<ClubFeePaymentResponse> 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<ClubFeePaymentResponse> 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<List<ClubFeePaymentResponse>> getFeePayments(
@PathVariable(name = "clubId") Integer clubId,
@UserId Integer requesterId
) {
return ResponseEntity.ok(clubFeePaymentService.getFeePayments(clubId, requesterId));
}

@Override
public ResponseEntity<ClubFeePaymentResponse> getMyFeePayment(
@PathVariable(name = "clubId") Integer clubId,
@UserId Integer requesterId
) {
return ResponseEntity.ok(clubFeePaymentService.getMyFeePayment(clubId, requesterId));
}
Comment on lines +24 to +62
}
Original file line number Diff line number Diff line change
@@ -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<Void> 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<ClubMemberSheetSyncResponse> syncMembersToSheet(
@PathVariable(name = "clubId") Integer clubId,
@RequestParam(name = "sortKey", defaultValue = "POSITION") ClubSheetSortKey sortKey,
@RequestParam(name = "ascending", defaultValue = "true") boolean ascending,
@UserId Integer requesterId
);
}
Original file line number Diff line number Diff line change
@@ -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<Void> 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<ClubMemberSheetSyncResponse> 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);
}
Comment on lines +25 to +45
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +5 to +7
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

[LEVEL: medium] paymentImageUrl에 필수/형식 검증이 없어 빈 문자열이나 비정상 값이 그대로 처리됩니다.
이 경우 제출 API는 통과하지만 이후 승인/조회 흐름에서 증빙 누락 데이터가 누적되어 운영 품질이 떨어집니다.
@NotBlank와 URL 형식 검증(예: @Pattern 또는 커스텀 validator)을 DTO에 추가해 요청 단계에서 차단해 주세요; As per coding guidelines src/main/java/**/*.java: "보안, 트랜잭션 경계, 예외 처리, N+1, 성능 회귀 가능성을 우선 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/gg/agit/konect/domain/club/dto/ClubFeePaymentSubmitRequest.java`
around lines 5 - 7, The DTO ClubFeePaymentSubmitRequest allows empty or invalid
paymentImageUrl values; add request-level validation by annotating the
paymentImageUrl component with `@NotBlank` and a URL-format validator (e.g.,
Hibernate Validator's `@URL` or a `@Pattern` regex / custom Constraint) so
invalid/empty URLs are rejected at binding; update imports and, if using a
custom constraint, implement/attach the validator and ensure controller
endpoints validate the DTO (e.g., `@Valid`) so invalid submissions are blocked
early.

) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Comment on lines +3 to +14
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +7 to +12
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

[LEVEL: medium] spreadsheetId@NotBlank만 적용되어 URL 전체 문자열이나 허용되지 않은 문자를 포함한 값도 통과됩니다.
이 값이 저장되면 등록 API는 성공해도 실제 동기화 시점에 Google API 호출 실패로 500이 발생할 수 있습니다(예: /d/.../edit 전체 URL 입력).
@Pattern(regexp = "^[A-Za-z0-9_-]+$") 같은 형식 검증을 추가해 요청 단계에서 400으로 차단해 주세요; As per coding guidelines src/main/java/**/*.java: "보안, 트랜잭션 경계, 예외 처리, N+1, 성능 회귀 가능성을 우선 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/gg/agit/konect/domain/club/dto/ClubSheetIdUpdateRequest.java`
around lines 7 - 12, The spreadsheetId field in ClubSheetIdUpdateRequest only
uses `@NotBlank` so full URLs or illegal characters can pass; add a format
validation such as `@Pattern`(regexp = "^[A-Za-z0-9_-]+$") to the String
spreadsheetId declaration (keep the existing `@NotBlank` and `@Schema`) so requests
with slashes or other disallowed chars are rejected at validation time and will
return 400 instead of causing Google API errors during sync.

) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package gg.agit.konect.domain.club.enums;

public enum ClubSheetSortKey {
NAME,
STUDENT_ID,
POSITION,
JOINED_AT,
FEE_PAID
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 14 additions & 0 deletions src/main/java/gg/agit/konect/domain/club/model/Club.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
}
Loading
Loading