Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package gg.agit.konect.domain.club.event;

import java.time.LocalDateTime;

public record SheetSyncFailedEvent(
Integer clubId,
String spreadsheetId,
boolean accessDenied,
String reason,
LocalDateTime occurredAt
) {

public static SheetSyncFailedEvent accessDenied(
Integer clubId,
String spreadsheetId,
String reason
) {
return new SheetSyncFailedEvent(clubId, spreadsheetId, true, reason, LocalDateTime.now());
}

public static SheetSyncFailedEvent unexpected(
Integer clubId,
String spreadsheetId,
String reason
) {
return new SheetSyncFailedEvent(clubId, spreadsheetId, false, reason, LocalDateTime.now());
}
Comment on lines +5 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Search for any event listeners that handle SheetSyncFailedEvent

# Check for `@EventListener` or `@TransactionalEventListener` handling this event
rg -n "SheetSyncFailedEvent" --type=java -g '!*Test*' -A 3 -B 1

Repository: BCSDLab/KONECT_BACK_END

Length of output: 4066


[LEVEL: medium] SheetSyncFailedEvent를 처리하는 리스너가 없습니다

문제: SheetSyncExecutor에서 SheetSyncFailedEvent를 발행하지만, 이벤트를 처리하는 @EventListener 또는 @TransactionalEventListener가 코드베이스에 존재하지 않습니다.

영향: 시트 동기화 실패 시 이벤트가 발행되어도 아무런 처리가 되지 않아, 사용자에게 오류를 알리거나 상태를 기록할 메커니즘이 동작하지 않습니다.

제안: 이 이벤트를 처리하는 리스너를 구현하여 실패 상태를 저장하거나 알림을 발송하세요.

🤖 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/event/SheetSyncFailedEvent.java`
around lines 5 - 27, Add an event listener to handle SheetSyncFailedEvent
(emitted from SheetSyncExecutor) by creating a Spring component (e.g.,
SheetSyncFailedEventListener) with an `@EventListener` or
`@TransactionalEventListener` method that accepts SheetSyncFailedEvent; inside the
handler, use the event fields (clubId, spreadsheetId, accessDenied, reason,
occurredAt) to persist a failure record or update club sync status via your
existing repository/service (e.g., ClubRepository or NotificationService) and
send notifications when accessDenied is true or for unexpected failures; ensure
the listener class is a `@Component` and performs any DB writes in a transactional
context if needed.

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
public class ClubSheetIntegratedService {

private final ClubPermissionValidator clubPermissionValidator;
private final GoogleSheetPermissionService googleSheetPermissionService;
private final SheetHeaderMapper sheetHeaderMapper;
private final ClubMemberSheetService clubMemberSheetService;
private final SheetImportService sheetImportService;
Expand All @@ -22,6 +23,9 @@ public SheetImportResponse analyzeAndImportPreMembers(
clubPermissionValidator.validateManagerAccess(clubId, requesterId);

String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl);
// Best-effort: OAuth 미연결/권한 부여 실패여도 이미 수동 공유된 시트는 그대로 읽을 수 있다.

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

여기 주석은 “Best-effort: OAuth 미연결/권한 부여 실패여도 계속 읽을 수 있다”라고 되어 있지만, tryGrantServiceAccountWriterAccess()는 Drive 서비스 초기화 실패/예상치 못한 IOException 시 CustomException을 던져 import 흐름을 중단시킬 수 있습니다. 실제 동작과 맞도록 주석을 조정하거나(중단되는 케이스 명시), 정말 best-effort로 가려면 예외를 잡아 로깅 후 계속 진행하도록 정리해 주세요.

Suggested change
// Best-effort: OAuth 미연결/권한 부여 실패여도 이미 수동 공유된 시트는 그대로 읽을 수 있다.
// Best-effort: 서비스 계정에 쓰기 권한 부여를 시도한다. OAuth 미연결/일반적인 권한 부여 실패가 있어도,
// 이미 수동 공유된 시트는 그대로 읽을 수 있다. 단, Drive 서비스 초기화 실패나 예기치 못한 I/O 오류 등에서는
// 예외가 전파되어 import 흐름이 중단될 수 있다.

Copilot uses AI. Check for mistakes.
googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);

Comment on lines +27 to +28

Copy link
Copy Markdown

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] tryGrantServiceAccountWriterAccess의 반환값이 무시됩니다.

문제: 메서드가 false를 반환해도 (OAuth 미연결, 권한 부여 실패) 후속 작업을 계속 진행합니다. 영향: 사용자는 "OAuth 미연결" 대신 "스프레드시트 접근 권한 없음"이라는 덜 명확한 오류 메시지를 받게 됩니다.

제안: 의도적인 "best-effort" 설계라면 주석으로 명시하고, 그렇지 않다면 false 반환 시 적절한 예외를 던지도록 수정하세요.

♻️ 명시적 처리 예시
         String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl);
-        googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
+        // best-effort: 권한 부여 실패 시에도 기존 권한으로 시도
+        googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);

또는 실패 시 명확한 에러 반환:

         String spreadsheetId = SpreadsheetUrlParser.extractId(spreadsheetUrl);
-        googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
+        boolean granted = googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId, spreadsheetId);
+        if (!granted) {
+            throw CustomException.of(ApiResponseCode.NOT_FOUND_GOOGLE_DRIVE_AUTH);
+        }
🤖 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/service/ClubSheetIntegratedService.java`
around lines 26 - 27, The call to
googleSheetPermissionService.tryGrantServiceAccountWriterAccess(requesterId,
spreadsheetId) in ClubSheetIntegratedService currently ignores its boolean
return value; update the code to handle false explicitly: either document with a
clear comment that this is an intentional best-effort and proceed, or
(preferred) check the returned boolean and throw an appropriate exception (e.g.,
PermissionDenied or a custom ClearSpreadsheetAccessException) when it returns
false so downstream code doesn't continue on failed grant; ensure the check
references tryGrantServiceAccountWriterAccess, requesterId and spreadsheetId so
reviewers can find the exact call to modify.

SheetHeaderMapper.SheetAnalysisResult analysis =
sheetHeaderMapper.analyzeAllSheets(spreadsheetId);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package gg.agit.konect.domain.club.service;

final class GoogleDrivePermissionHelper {

private static final int ROLE_RANK_NONE = 0;
private static final int ROLE_RANK_READER = 1;
private static final int ROLE_RANK_COMMENTER = 2;
private static final int ROLE_RANK_WRITER = 3;

private GoogleDrivePermissionHelper() {}

enum PermissionApplyStatus {
CREATED,
UPGRADED,
UNCHANGED
}

static boolean hasRequiredRole(String currentRole, String targetRole) {
return roleRank(currentRole) >= roleRank(targetRole);
}

private static int roleRank(String role) {
if (role == null) {
return ROLE_RANK_NONE;
}

return switch (role) {
case "reader" -> ROLE_RANK_READER;
case "commenter" -> ROLE_RANK_COMMENTER;
case "writer", "fileOrganizer", "organizer", "owner" -> ROLE_RANK_WRITER;
default -> ROLE_RANK_NONE;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package gg.agit.konect.domain.club.service;

import java.io.IOException;
import java.util.List;
import java.util.Set;

import com.google.api.client.googleapis.json.GoogleJsonError;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.HttpResponseException;

import gg.agit.konect.global.code.ApiResponseCode;
import gg.agit.konect.global.exception.CustomException;

public final class GoogleSheetApiExceptionHelper {

private static final Set<String> ACCESS_DENIED_REASONS = Set.of(
"accessDenied",
"forbidden",
"insufficientFilePermissions",
"insufficientPermissions",
"notAuthorized",
"required"
);
private static final Set<String> AUTH_FAILURE_REASONS = Set.of(
"authError",
"invalidCredentials",
"unauthorized"
);
private static final int HTTP_STATUS_FORBIDDEN = 403;
private static final int HTTP_STATUS_UNAUTHORIZED = 401;
private static final int HTTP_STATUS_NOT_FOUND = 404;

private GoogleSheetApiExceptionHelper() {}

public static boolean isAccessDenied(IOException exception) {
if (exception instanceof GoogleJsonResponseException responseException) {
if (responseException.getStatusCode() != HTTP_STATUS_FORBIDDEN) {
return false;
}
return hasReason(responseException, ACCESS_DENIED_REASONS);
}
return getStatusCode(exception) == HTTP_STATUS_FORBIDDEN;
}

public static boolean isAuthFailure(IOException exception) {
if (exception instanceof GoogleJsonResponseException responseException) {
if (responseException.getStatusCode() != HTTP_STATUS_UNAUTHORIZED) {
return false;
}
return hasReason(responseException, AUTH_FAILURE_REASONS)
|| !hasKnownReasons(responseException);
}
return getStatusCode(exception) == HTTP_STATUS_UNAUTHORIZED;
}

public static boolean isNotFound(IOException exception) {
return getStatusCode(exception) == HTTP_STATUS_NOT_FOUND;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public static CustomException accessDenied() {
return CustomException.of(ApiResponseCode.FORBIDDEN_GOOGLE_SHEET_ACCESS);
}

private static boolean hasReason(
GoogleJsonResponseException exception,
Set<String> expectedReasons
) {
return getReasons(exception).stream()
.anyMatch(expectedReasons::contains);
}

private static boolean hasKnownReasons(GoogleJsonResponseException exception) {
return !getReasons(exception).isEmpty();
}

private static List<String> getReasons(GoogleJsonResponseException exception) {
if (exception.getDetails() == null || exception.getDetails().getErrors() == null) {
return List.of();
}
return exception.getDetails().getErrors().stream()
.map(GoogleJsonError.ErrorInfo::getReason)
.filter(reason -> reason != null && !reason.isBlank())
.toList();
}
Comment on lines +35 to +84

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

GlobalExceptionHandlerCustomException#getFullMessage()를 응답의 message로 사용하고 있어, 여기서 detail을 포함해 예외를 만들면 클라이언트 메시지에 기본메시지: fileId=... 같은 기술적 정보가 그대로 노출됩니다. 사용자 안내용 403 메시지는 ApiResponseCode 기본 메시지로 유지하고, detail은 로그/메트릭에만 남기거나(또는 응답에 별도 필드로 분리)하도록 예외 생성 방식을 조정하는 것이 안전합니다.

Copilot uses AI. Check for mistakes.

private static int getStatusCode(IOException exception) {
if (exception instanceof GoogleJsonResponseException responseException) {
return responseException.getStatusCode();
}
if (exception instanceof HttpResponseException responseException) {
return responseException.getStatusCode();
}
return -1;
Comment on lines +86 to +93

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

getStatusCode()GoogleJsonResponseException만 처리하고 그 외 IOException(예: JSON이 아닌 오류 응답으로 발생 가능한 HttpResponseException)는 항상 -1을 반환합니다. 그 결과 isAccessDenied/isAuthFailure/isNotFound의 fallback(getStatusCode(exception) == ...)가 동작하지 않아 비-JSON 오류 응답을 권한/인증/404로 분류하지 못합니다. HttpResponseException까지 상태 코드를 추출하도록 보완하거나, fallback 로직을 제거/정리해 오해가 없게 해주세요.

Copilot uses AI. Check for mistakes.
}
Comment on lines +86 to +94

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

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

GoogleSheetApiExceptionHelper.getStatusCode()가 GoogleJsonResponseException만 처리하고 있어서, isAccessDenied/isAuthFailure/isNotFound의 fallback 분기(비-JSON 응답)에서는 항상 -1을 반환합니다. HttpResponseException(=IOException)도 statusCode를 갖기 때문에, getStatusCode에서 HttpResponseException까지 처리하거나 아예 fallback 로직을 정리해 실제로 비-JSON 오류도 분류되도록 수정해 주세요.

Copilot uses AI. Check for mistakes.
}
Loading
Loading