-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 구글 스프레드시트 API MVP 구현 #397
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
7d47e18
75e88e0
d51b6ff
3a93a1a
3207516
d03fa98
6535eee
b0604ca
60a90e9
5b78723
916cc61
bcf5c3c
ae151f3
d453f38
d0ddd96
23fc56b
cef5033
a0477d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ); | ||
|
|
||
| @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LEVEL: medium] 🤖 Prompt for AI Agents |
||
| ) { | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LEVEL: medium] 🤖 Prompt for AI Agents |
||
| ) { | ||
| } | ||
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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
🤖 Prompt for AI Agents