From 935609d5f0f44e31c86580d2f2b40b754092b5cc Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Tue, 7 Apr 2026 00:27:58 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore:=20github-api=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build.gradle b/build.gradle index 3a0f8e7..1bba461 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // GitHub API + implementation 'org.kohsuke:github-api:1.321' + } tasks.named('test') { From f11afd13367453eec36c0a023aa4ee6832324034 Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Tue, 7 Apr 2026 00:39:05 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Git=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B0=8F=20=EC=BB=A4=EB=B0=8B=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20-=20Githu?= =?UTF-8?q?b=20access=20token=20=EB=93=B1=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20-=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84(Github=20api=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99)=20-=20=EC=BB=A4=EB=B0=8B=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=EA=B8=B0=EB=B0=98=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4)=20-=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/git/controller/GitController.java | 133 +++++++++--- .../server/domain/git/dto/GitRequest.java | 13 ++ .../server/domain/git/dto/GitResponse.java | 124 +++++++++++ .../server/domain/git/entity/Commit.java | 29 ++- .../server/domain/git/entity/Repository.java | 25 +++ .../domain/git/exception/GitErrorCode.java | 39 ++++ .../GitTokenNotRegisteredException.java | 11 + .../RepositoryNotFoundException.java | 9 + .../git/repository/CommitRepository.java | 46 ++++ .../git/repository/RepositoryRepository.java | 16 ++ .../domain/git/service/GitCommandService.java | 23 ++ .../git/service/GitCommandServiceImpl.java | 197 ++++++++++++++++++ .../domain/git/service/GitQueryService.java | 28 +++ .../git/service/GitQueryServiceImpl.java | 123 +++++++++++ .../server/domain/user/entity/Member.java | 6 + .../server/global/util/github/GitHubUtil.java | 57 +++++ 16 files changed, 852 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java create mode 100644 src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java create mode 100644 src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java create mode 100644 src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java create mode 100644 src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java create mode 100644 src/main/java/com/whylog/server/domain/git/service/GitCommandService.java create mode 100644 src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java create mode 100644 src/main/java/com/whylog/server/domain/git/service/GitQueryService.java create mode 100644 src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java create mode 100644 src/main/java/com/whylog/server/global/util/github/GitHubUtil.java diff --git a/src/main/java/com/whylog/server/domain/git/controller/GitController.java b/src/main/java/com/whylog/server/domain/git/controller/GitController.java index a916f6b..d75616d 100644 --- a/src/main/java/com/whylog/server/domain/git/controller/GitController.java +++ b/src/main/java/com/whylog/server/domain/git/controller/GitController.java @@ -2,18 +2,18 @@ import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.service.GitCommandService; +import com.whylog.server.domain.git.service.GitQueryService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -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 org.springframework.web.bind.annotation.RestController; - +import org.springframework.data.domain.Slice; +import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @@ -22,39 +22,122 @@ @Tag(name = "Git", description = "깃 관련 API") public class GitController { + private final GitCommandService gitCommandService; + private final GitQueryService gitQueryService; + + @PostMapping("/github/token") + @Operation( + summary = "GitHub Access Token 등록 API", + description = """ + GitHub API를 사용하기 위한 Personal Access Token을 등록하는 API입니다. + - 사용자가 GitHub에서 발급한 Personal Access Token을 저장합니다. + - 저장된 토큰은 이후 레포지토리 동기화 시 자동으로 사용됩니다. + - GitHub Personal Access Token 발급 필요 (repo 권한 포함) + - GitHub Settings > Developer settings > Personal access tokens에서 발급 + """) + public ApiResponse registerGitHubToken( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody GitRequest.GitHubTokenDTO request) { + return ApiResponse.onSuccess(gitCommandService.registerGitHubToken(memberId, request.getAccessToken())); + } + @GetMapping("/teams/{teamId}/repositories") - @Operation(summary = "레포 목록 조회 API", description = "특정 팀의 레포 목록을 조회하는 API입니다.") + @Operation( + summary = "팀의 연동된 레포지토리 목록 조회", + description = """ + 팀에 연동된 GitHub 레포지토리 목록을 조회합니다.(페이징 없음) + + 정렬 순서: + 1. 최근 동기화한 레포지토리 (동기화 시간 최신순) + 2. 동기화된 적 없는 레포지토리 (추가한순) + """) public ApiResponse> getRepositories( @PathVariable Long teamId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess( + gitQueryService.getRepositories(teamId).stream() + .map(GitResponse.RepositoryDTO::from) + .toList() + ); } @PostMapping("/teams/{teamId}/repositories") - @Operation(summary = "레포 추가 API", description = "팀에 새로운 레포를 추가하는 API입니다.") + @Operation( + summary = "GitHub 레포지토리 추가", + description = "GitHub 레포지토리를 팀에 연동합니다. 등록 시에는 레포 정보만 저장되며, 커밋은 동기화 API를 호출할 때 수집됩니다.") public ApiResponse createRepository( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody GitRequest.RepositoryCreateDTO request) { - return ApiResponse.onSuccess(null); + var repository = gitCommandService.createRepository(memberId, teamId, request); + return ApiResponse.onSuccess(GitResponse.RepositoryCreateResponseDTO.from(repository)); } - @GetMapping("/repositories/{repositoryId}/commits") - @Operation(summary = "커밋 목록 조회 API", description = "특정 레포의 커밋 목록을 조회하는 API입니다.") - public ApiResponse> getCommits( + @PostMapping("/repositories/{repositoryId}/sync") + @Operation( + summary = "GitHub 레포지토리 동기화", + description = "등록된 레포지토리의 최신 커밋을 DB에 저장합니다. 마지막 동기화 이후의 새 커밋만 저장되며 Merge 커밋은 제외됩니다.") + public ApiResponse syncRepository( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long repositoryId) { - return ApiResponse.onSuccess(null); + + gitCommandService.syncRepository(memberId, repositoryId); + return ApiResponse.onSuccess(GitResponse.RepositorySyncResponseDTO.from(repositoryId)); } - @GetMapping("/commits/{commitId}") - @Operation(summary = "커밋 상세 조회 API", description = "특정 커밋의 상세 정보를 조회하는 API입니다.") - public ApiResponse getCommitDetail( - @PathVariable Long commitId) { - return ApiResponse.onSuccess(null); + @GetMapping("/repositories/{repositoryId}/commits") + @Operation( + summary = "커밋 목록 조회 (커서 기반 무한스크롤)", + description = """ + 10개씩 커밋을 조회합니다. + + 📌 사용 방법: + 1. 첫 요청: cursor 파라미터 없음 + 2. 응답의 hasNext가 true면, nextCursorId를 cursor로 다시 요청 + 3. hasNext가 false가 나올 때까지 반복 + + 💡 응답 필드: + - hasNext: 다음 페이지 존재 여부 (더 불러올 커밋이 있으면 true) + - nextCursorId: 다음 요청에 사용할 커서 ID + - isFirst: 첫 페이지 여부 + """) + public ApiResponse getCommitsCursor( + @PathVariable Long repositoryId, + @Parameter(description = "이전 조회의 마지막 커밋 ID (첫 요청 시 생략)") + @RequestParam(required = false) Long cursor) { + + Slice commitSlice = gitQueryService.getCommitsByRepository( + repositoryId, + cursor + ); + + return ApiResponse.onSuccess(GitResponse.CommitListResponseDTO.from(commitSlice, cursor)); } - @PostMapping("/repositories/{repositoryId}/sync") - @Operation(summary = "레포 동기화 API", description = "레포를 동기화하여 최신 커밋 정보를 가져오는 API입니다.") - public ApiResponse syncRepository( - @PathVariable Long repositoryId) { - return ApiResponse.onSuccess(null); + @GetMapping("/repositories/{repositoryId}/commits/{commitHash}") + @Operation( + summary = "커밋 상세 조회", + description = """ + 커밋의 전체 정보와 변경된 파일 목록을 조회합니다. + + - DB에서 가져오는 정보: hash, message, authorName, authorEmail, authorProfileImage, dateTime, description, changedFileCount + - (참고) authorName, authorEmail, authorProfileImage는 깃허브에서 가져와서 저장한 정보입니다. + - GitHub API에서 실시간 조회: changedFileList (변경된 파일 및 코드) + + ⚙️ 처리 방식: + - GitHub API 호출 완료 후 응답 (비동기 아님) + - GitHub API 호출 실패 시 변경 파일 리스트는 빈 배열로 반환 + + 💡 프론트 구현시 참고 사항: + - changedCode를 react-diff-viewer-continued 라이브러리 사용하면 될거같으니 참고해주세요! + """) + public ApiResponse getCommitDetail( + @Parameter(hidden = true) @CurrentMember Long memberId, + @PathVariable Long repositoryId, + @PathVariable String commitHash) { + GitResponse.CommitDetailDTO commitDetail = gitQueryService.getCommitByHash(memberId, repositoryId, commitHash); + return ApiResponse.onSuccess(commitDetail); } } + + + diff --git a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java index a17ef37..5f616e9 100644 --- a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java +++ b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.git.dto; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -23,5 +24,17 @@ public static class RepositoryCreateDTO { private String url; } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "GitHub Access Token 등록 요청") + public static class GitHubTokenDTO { + + @Schema(description = "GitHub Access Token", example = "ghp_jv******") + @NotBlank + @JsonProperty("access_token") + private String accessToken; + } } diff --git a/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java b/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java index 7b3ff0f..ac34fd0 100644 --- a/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java +++ b/src/main/java/com/whylog/server/domain/git/dto/GitResponse.java @@ -1,12 +1,16 @@ package com.whylog.server.domain.git.dto; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.data.domain.Slice; import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; public class GitResponse { @@ -25,6 +29,14 @@ public static class RepositoryDTO { @Schema(description = "마지막 동기화 시간", example = "2026-03-25T10:30:00") private LocalDateTime lastSyncDateTime; + + public static RepositoryDTO from(Repository repository) { + return RepositoryDTO.builder() + .repositoryId(repository.getId()) + .name(repository.getName()) + .lastSyncDateTime(repository.getLastSyncedAt()) + .build(); + } } @Getter @@ -42,6 +54,14 @@ public static class RepositoryCreateResponseDTO { @Schema(description = "레포 URL", example = "https://github.com/WhyLog-App/WhyLog-BE") private String url; + + public static RepositoryCreateResponseDTO from(Repository repository) { + return RepositoryCreateResponseDTO.builder() + .repositoryId(repository.getId()) + .name(repository.getName()) + .url(repository.getUrl()) + .build(); + } } @Getter @@ -71,6 +91,18 @@ public static class CommitDTO { @Schema(description = "삭제된 줄 수", example = "12") private Integer deletedLines; + + public static CommitDTO from(Commit commit) { + return CommitDTO.builder() + .commitId(commit.getId()) + .hash(commit.getHash()) + .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .dateTime(commit.getDateTime()) + .addedLines(commit.getAddedLines()) + .deletedLines(commit.getDeletedLines()) + .build(); + } } @Getter @@ -95,15 +127,36 @@ public static class CommitDetailDTO { @Schema(description = "작성자 이메일", example = "user@example.com") private String authorEmail; + @Schema(description = "작성자 프로필 사진", example = "https://img.com/profile.jpg") + private String authorProfileImage; + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") private LocalDateTime dateTime; @Schema(description = "설명", example = "사용자 마이페이지 조회 및 수정 API를 RESTful 방식으로 구현했습니다.") private String description; + @Schema(description = "변경된 파일 개수", example = "2") + private Integer changedFileCount; + @Schema(description = "변경된 파일 목록") private List changedFileList; + public static CommitDetailDTO of(Commit commit, List changedFileList) { + return CommitDetailDTO.builder() + .commitId(commit.getId()) + .hash(commit.getHash()) + .message(commit.getMessage()) + .authorName(commit.getAuthorName()) + .authorEmail(commit.getAuthorEmail()) + .authorProfileImage(commit.getAuthorProfileImage()) + .dateTime(commit.getDateTime()) + .description(commit.getMessage()) //TODO: AI 요약값으로 변경 + .changedFileCount(changedFileList.size()) + .changedFileList(changedFileList) + .build(); + } + @Getter @NoArgsConstructor @AllArgsConstructor @@ -116,6 +169,12 @@ public static class ChangedFileDTO { @Schema(description = "변경된 코드", example = "+ export const getMyPage = async (req, res) => {") private String changedCode; + + @Schema(description = "추가된 줄 수", example = "5") + private Integer addedLines; + + @Schema(description = "삭제된 줄 수", example = "2") + private Integer deletedLines; } } @@ -128,5 +187,70 @@ public static class RepositorySyncResponseDTO { @Schema(description = "레포 ID", example = "1") private Long repositoryId; + + public static RepositorySyncResponseDTO from(Long repositoryId) { + return RepositorySyncResponseDTO.builder() + .repositoryId(repositoryId) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "깃허브 Access Token 등록 응답") + public static class GitHubTokenResponseDTO { + + @Schema(description = "GitHub Access Token", example = "ghp_jv******") + private String accessToken; + + public static GitHubTokenResponseDTO from(String accessToken) { + return GitHubTokenResponseDTO.builder() + .accessToken(accessToken) + .build(); + } + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "커서 기반 무한스크롤 커밋 목록 응답") + public static class CommitListResponseDTO { + + @Schema(description = "커밋 목록") + private List commitDTOList; + + @Schema(description = "현재 페이지의 커밋 개수", example = "10") + private Integer commitListSize; + + @Schema(description = "페이지 처음 여부", example = "true") + private Boolean isFirst; + + @Schema(description = "다음 페이지가 있는지 여부", example = "true") + private Boolean hasNext; + + @Schema(description = "다음 커서 ID (무한스크롤용)", example = "1") + private Long nextCursorId; + + public static CommitListResponseDTO from(Slice commitSlice, Long cursorId) { + List commitDTOs = commitSlice.getContent().stream() + .map(CommitDTO::from) + .collect(Collectors.toList()); + + // 다음 커서 ID 설정 + Long nextCursorId = commitSlice.hasNext() && !commitDTOs.isEmpty() + ? commitDTOs.get(commitDTOs.size() - 1).getCommitId() + : null; + + return CommitListResponseDTO.builder() + .commitDTOList(commitDTOs) + .commitListSize(commitDTOs.size()) + .isFirst(cursorId == null) + .hasNext(commitSlice.hasNext()) + .nextCursorId(nextCursorId) + .build(); + } } } diff --git a/src/main/java/com/whylog/server/domain/git/entity/Commit.java b/src/main/java/com/whylog/server/domain/git/entity/Commit.java index 040f868..3dc77d9 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/Commit.java +++ b/src/main/java/com/whylog/server/domain/git/entity/Commit.java @@ -34,10 +34,10 @@ public class Commit extends BaseEntity { @JoinColumn(name = "repository_id", nullable = false) private Repository repository; - @Column(length = 255, nullable = false) + @Column(nullable = false) private String hash; - @Column(length = 255, nullable = false) + @Column(nullable = false) private String message; @Column(name = "author_name", length = 50, nullable = false) @@ -46,9 +46,18 @@ public class Commit extends BaseEntity { @Column(name = "author_email", length = 50, nullable = false) private String authorEmail; + @Column(name = "author_profile_image", length = 255, nullable = false) + private String authorProfileImage; + @Column(name = "datetime", nullable = false) private LocalDateTime dateTime; + @Column(name = "added_lines", nullable = false) + private Integer addedLines; + + @Column(name = "deleted_lines", nullable = false) + private Integer deletedLines; + // @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) // private final List changedFiles = new ArrayList<>(); // @@ -57,4 +66,20 @@ public class Commit extends BaseEntity { // // @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) // private final List commitConnections = new ArrayList<>(); + + public static Commit create(String hash, String message, String authorName, String authorEmail, + String authorProfileImage, LocalDateTime dateTime, Integer addedLines, + Integer deletedLines, Repository repository) { + Commit commit = new Commit(); + commit.hash = hash; + commit.message = message; + commit.authorName = authorName; + commit.authorEmail = authorEmail; + commit.authorProfileImage = authorProfileImage; + commit.dateTime = dateTime; + commit.addedLines = addedLines; + commit.deletedLines = deletedLines; + commit.repository = repository; + return commit; + } } diff --git a/src/main/java/com/whylog/server/domain/git/entity/Repository.java b/src/main/java/com/whylog/server/domain/git/entity/Repository.java index 4cfabb4..eb55d9c 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/Repository.java +++ b/src/main/java/com/whylog/server/domain/git/entity/Repository.java @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -30,10 +31,34 @@ public class Repository extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(length = 255, nullable = false) + private String name; + + @Column(length = 500, nullable = false) + private String url; + + @Column(nullable = true) + private LocalDateTime lastSyncedAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "team_id", nullable = false) private Team team; // @OneToMany(mappedBy = "repository", cascade = CascadeType.ALL, orphanRemoval = true) // private final List commits = new ArrayList<>(); + + public static Repository create(String name, String url, Team team) { + Repository repository = new Repository(); + repository.name = name; + repository.url = url; + repository.team = team; + return repository; + } + + /** + * 마지막 동기화 시간을 업데이트합니다. + */ + public void updateLastSyncedAt(LocalDateTime syncedAt) { + this.lastSyncedAt = syncedAt; + } } diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java new file mode 100644 index 0000000..eccbf0a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java @@ -0,0 +1,39 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GitErrorCode implements BaseErrorCode { + REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_1", "존재하지 않는 레포지토리입니다."), + COMMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_2", "존재하지 않는 커밋입니다."), + INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "GIT_400_1", "유효하지 않은 GitHub URL입니다."), + GITHUB_TOKEN_NOT_REGISTERED(HttpStatus.BAD_REQUEST, "GIT_400_2", "GitHub Access Token이 등록되지 않았습니다."),; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java b/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java new file mode 100644 index 0000000..2cf3d54 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/GitTokenNotRegisteredException.java @@ -0,0 +1,11 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class GitTokenNotRegisteredException extends GeneralException { + + public GitTokenNotRegisteredException() { + super(GitErrorCode.GITHUB_TOKEN_NOT_REGISTERED); + } + +} diff --git a/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java b/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java new file mode 100644 index 0000000..8930368 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/exception/RepositoryNotFoundException.java @@ -0,0 +1,9 @@ +package com.whylog.server.domain.git.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class RepositoryNotFoundException extends GeneralException { + + public RepositoryNotFoundException() {super(GitErrorCode.REPOSITORY_NOT_FOUND);} + +} diff --git a/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java b/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java new file mode 100644 index 0000000..5db73b6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/repository/CommitRepository.java @@ -0,0 +1,46 @@ +package com.whylog.server.domain.git.repository; + +import com.whylog.server.domain.git.entity.Commit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +public interface CommitRepository extends JpaRepository { + + // 레포지토리 ID와 해시로 커밋 조회 + Optional findByRepositoryIdAndHash(Long repositoryId, String hash); + + // 해시 조회 + @Query("SELECT c.hash FROM Commit c " + + "WHERE c.repository.id = :repositoryId " + + "AND c.hash IN :hashes") + Set findExistingHashes( + @Param("repositoryId") Long repositoryId, + @Param("hashes") List hashes + ); + + // 커서 기반 무한스크롤 - 커밋 목록 조회 + @Query("SELECT c FROM Commit c " + + "WHERE c.repository.id = :repositoryId " + + "AND (" + + " :cursorId IS NULL " + + " OR c.dateTime < (SELECT sub.dateTime FROM Commit sub WHERE sub.id = :cursorId) " + + " OR (c.dateTime = (SELECT sub2.dateTime FROM Commit sub2 WHERE sub2.id = :cursorId) AND c.id < :cursorId)" + + ") " + + "ORDER BY c.dateTime DESC, c.id DESC") + Slice findCommitsWithCursor( + @Param("repositoryId") Long repositoryId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); +} + + diff --git a/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java b/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java new file mode 100644 index 0000000..8cbf8c5 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/repository/RepositoryRepository.java @@ -0,0 +1,16 @@ +package com.whylog.server.domain.git.repository; + +import com.whylog.server.domain.git.entity.Repository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; + +public interface RepositoryRepository extends JpaRepository { + + // 팀의 레포지토리를 최근 동기화 시간 기준 내림차순으로 조회 + @Query("SELECT r FROM Repository r " + + "WHERE r.team.id = :teamId " + + "ORDER BY r.lastSyncedAt DESC NULLS LAST, r.id DESC") + List findByTeamId(@Param("teamId") Long teamId); +} diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java new file mode 100644 index 0000000..519b532 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java @@ -0,0 +1,23 @@ +package com.whylog.server.domain.git.service; + +import com.whylog.server.domain.git.dto.GitRequest; +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Repository; + +public interface GitCommandService { + + /** + * 사용자의 GitHub Access Token을 등록합니다. + */ + GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken); + + /** + * 팀에 새로운 레포지토리를 추가합니다. + */ + Repository createRepository(Long memberId, Long teamId, GitRequest.RepositoryCreateDTO request); + + /** + * 레포지토리를 동기화합니다. + */ + GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId); +} diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java new file mode 100644 index 0000000..a701e3c --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -0,0 +1,197 @@ +package com.whylog.server.domain.git.service; + +import com.whylog.server.domain.git.dto.GitRequest; +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.repository.CommitRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.util.github.GitHubUtil; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GitHub; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GitCommandServiceImpl implements GitCommandService { + + private final RepositoryRepository repositoryRepository; + private final CommitRepository commitRepository; + private final TeamUseCase teamUseCase; + private final MemberUseCase memberUseCase; + + /** + * 사용자의 GitHub Access Token을 등록합니다. + */ + @Override + @Transactional + public GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken) { + if (accessToken == null || accessToken.isEmpty()) throw new ParameterRequiredException(); + + Member member = memberUseCase.findMemberById(memberId); + member.setGithubAccessToken(accessToken); + + return GitResponse.GitHubTokenResponseDTO.builder() + .accessToken(accessToken) + .build(); + } + + /** + * 팀에 새로운 레포지토리를 추가합니다. + */ + @Override + @Transactional + public Repository createRepository(Long memberId, Long teamId, GitRequest.RepositoryCreateDTO request) { + if (teamId == null) throw new ParameterRequiredException(); + + // 현재 사용자의 GitHub Token 조회 + Member member = memberUseCase.findMemberById(memberId); + String accessToken = member.getGithubAccessToken(); + + // 레포 URL 유효성 검증 및 GitHub에서 존재 여부 확인 + GitHubUtil.validateRepositoryExists(request.getUrl(), accessToken); + + Team team = teamUseCase.findTeamById(teamId); + Repository repository = Repository.create(request.getName(), request.getUrl(), team); + return repositoryRepository.save(repository); + } + + /** + * 레포지토리를 동기화합니다 (커밋 저장) + */ + @Override + @Transactional + public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId) { + if (memberId == null) throw new ParameterRequiredException(); + if (repositoryId == null) throw new ParameterRequiredException(); + + // 사용자의 GitHub Token 조회 및 검증 + Member member = memberUseCase.findMemberById(memberId); + String accessToken = member.getGithubAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new GitTokenNotRegisteredException(); + } + + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + try { + GitHub gitHub = GitHubUtil.createGitHubInstance(accessToken); + String repoPath = GitHubUtil.extractRepoPath(repository.getUrl()); + GHRepository ghRepository = gitHub.getRepository(repoPath); + + // 마지막 동기화 시간 이후의 커밋만 저장 + LocalDateTime lastSyncedAt = repository.getLastSyncedAt(); + syncCommits(ghRepository, repository, lastSyncedAt); + + // 동기화 시간 업데이트 + repository.updateLastSyncedAt(LocalDateTime.now()); + + } catch (IOException e) { + throw new RepositoryNotFoundException(); + } + + return GitResponse.RepositorySyncResponseDTO.builder() + .repositoryId(repositoryId) + .build(); + } + + /** + * GitHub에서 마지막 동기화 시간 이후의 커밋을 조회하고 DB에 일괄 저장합니다. + */ + private void syncCommits(GHRepository ghRepository, Repository repository, LocalDateTime lastSyncedAt) { + try { + var commitQuery = ghRepository.queryCommits(); + + // 시간 필터링 + if (lastSyncedAt != null) { + java.util.Date sinceDate = java.util.Date.from( + lastSyncedAt.atZone(ZoneId.systemDefault()).toInstant() + ); + commitQuery.since(sinceDate); // 이 시간 이후의 커밋만 달라고 요청 + } + + // GitHub에서 가져온 모든 커밋을 리스트로 변환 + List allGitHubCommits = commitQuery.list().toList(); + + // 모든 커밋의 hash를 추출 + List allHashes = allGitHubCommits.stream() + .map(org.kohsuke.github.GHCommit::getSHA1) + .toList(); + + // DB에 이미 존재하는 hash들을 조회 + Set existingHashes = allHashes.isEmpty() + ? java.util.Collections.emptySet() + : commitRepository.findExistingHashes(repository.getId(), allHashes); + + //필요한 커밋만 필터링 및 변환 + List newCommits = allGitHubCommits.stream() + .filter(ghCommit -> !existingHashes.contains(ghCommit.getSHA1())) + .map(ghCommit -> { + try { + // 커밋 정보를 한 번에 조회 + var shortInfo = ghCommit.getCommitShortInfo(); + String message = shortInfo.getMessage(); + + // Merge pull request 커밋 제외 + if (message.startsWith("Merge pull request")) { + return null; + } + + // 작성자 프로필 이미지 URL 조회 + String authorProfileImage = (ghCommit.getAuthor() != null) + ? ghCommit.getAuthor().getAvatarUrl() + : null; + + return Commit.create( + ghCommit.getSHA1(), + message, + shortInfo.getAuthor().getName(), + shortInfo.getAuthor().getEmail(), + authorProfileImage, + shortInfo.getAuthor().getDate() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(), + ghCommit.getLinesAdded(), + ghCommit.getLinesDeleted(), + repository + ); + } catch (Exception e) { + log.warn("커밋 변환 실패 [{}]: {}", ghCommit.getSHA1(), e.getMessage()); + return null; + } + }) + .filter(java.util.Objects::nonNull) + .toList(); + + // db insert + if (!newCommits.isEmpty()) { + commitRepository.saveAll(newCommits); + log.info("새로운 커밋 {}개 동기화 완료: {}", newCommits.size(), ghRepository.getFullName()); + } else { + log.info("새로 동기화할 커밋이 없습니다: {}", ghRepository.getFullName()); + } + + } catch (IOException e) { + throw new RepositoryNotFoundException(); + } + } +} diff --git a/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java b/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java new file mode 100644 index 0000000..6406084 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryService.java @@ -0,0 +1,28 @@ +package com.whylog.server.domain.git.service; + +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import org.springframework.data.domain.Slice; + +import java.util.List; + +public interface GitQueryService { + + /** + * 특정 팀의 레포지토리 목록을 조회합니다. + */ + List getRepositories(Long teamId); + + /** + * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API에서 살시간으로 조회) + */ + GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositoryId, String hash); + + /** + * 커서 기반 무한스크롤 - 커밋 목록 조회 (페이지 크기 고정: 10) + * cursorId가 null이면 첫 페이지, 있으면 다음 페이지 + */ + Slice getCommitsByRepository(Long repositoryId, Long cursorId); + +} diff --git a/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java new file mode 100644 index 0000000..7e40fcf --- /dev/null +++ b/src/main/java/com/whylog/server/domain/git/service/GitQueryServiceImpl.java @@ -0,0 +1,123 @@ +package com.whylog.server.domain.git.service; +import com.whylog.server.domain.git.dto.GitResponse; +import com.whylog.server.domain.git.entity.Commit; +import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.domain.git.repository.CommitRepository; +import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.util.github.GitHubUtil; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GHCommit; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class GitQueryServiceImpl implements GitQueryService { + + private static final int PAGE_SIZE = 10; + + private final RepositoryRepository repositoryRepository; + private final CommitRepository commitRepository; + private final MemberUseCase memberUseCase; + private final TeamUseCase teamUseCase; + + /** + * 특정 팀의 레포지토리 목록을 조회합니다. + */ + public List getRepositories(Long teamId) { + + // null 체크 + if (teamId == null) throw new ParameterRequiredException(); + + // 팀 존재 여부 검증 + teamUseCase.findTeamById(teamId); + + return repositoryRepository.findByTeamId(teamId); + } + + /** + * 커서 기반 무한스크롤 - 커밋 목록 조회 + * cursorId가 null이면 첫 페이지, 있으면 다음 페이지 + * 페이지 크기 고정: 10 + */ + public Slice getCommitsByRepository(Long repositoryId, Long cursorId) { + + // 레포 확인 + repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + Pageable pageable = PageRequest.of(0, PAGE_SIZE); + return commitRepository.findCommitsWithCursor(repositoryId, cursorId, pageable); + } + + /** + * 특정 커밋을 조회합니다. (변경된 파일 정보는 GitHub API를 통해 살시간으로 조회) + */ + public GitResponse.CommitDetailDTO getCommitByHash(Long memberId, Long repositoryId, String hash) { + // null 체크 + if (memberId == null || repositoryId == null || hash == null) { + throw new ParameterRequiredException(); + } + + // DB에서 커밋 정보 조회 + Commit commit = commitRepository.findByRepositoryIdAndHash(repositoryId, hash) + .orElseThrow(() -> new ErrorHandler(GitErrorCode.COMMIT_NOT_FOUND)); + + // 사용자의 GitHub Token 조회 + Member member = memberUseCase.findMemberById(memberId); + String accessToken = member.getGithubAccessToken(); + + if (accessToken == null || accessToken.isEmpty()) { + throw new GitTokenNotRegisteredException(); + } + + // GitHub API에서 변경된 파일 정보 조회 + List changedFiles = List.of(); + + try { + Repository repository = repositoryRepository.findById(repositoryId) + .orElseThrow(RepositoryNotFoundException::new); + + GitHub gitHub = GitHubUtil.createGitHubInstance(accessToken); + String repoPath = GitHubUtil.extractRepoPath(repository.getUrl()); + GHCommit ghCommit = gitHub.getRepository(repoPath).getCommit(hash); + + // 변경된 파일 정보 조회 및 CommitDetailDTO.ChangedFileDTO로 변환 + changedFiles = ghCommit.listFiles().toList().stream() + .map(file -> GitResponse.CommitDetailDTO.ChangedFileDTO.builder() + .fileName(file.getFileName()) + .changedCode(file.getPatch() != null ? file.getPatch() : "") + .addedLines(file.getLinesAdded()) + .deletedLines(file.getLinesDeleted()) + .build()) + .toList(); + + log.info("GitHub API에서 변경된 파일 정보 조회 완료: {} files", changedFiles.size()); + + } catch (IOException e) { + log.warn("GitHub API 호출 실패 (변경 파일 정보): {}", e.getMessage()); + // GitHub API 실패해도 DB의 커밋 정보는 반환 (파일 정보는 빈 리스트) + } + + // DB에 저장된 정보와 GitHub API에서 조회한 파일 정보를 포함해서 반환 + return GitResponse.CommitDetailDTO.of(commit, changedFiles); + } +} diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 4729058..e42a4c4 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -43,6 +43,9 @@ public class Member extends BaseEntity { @Column(nullable = false, length = 20) private Role role; + @Column(name = "github_access_token", length = 500, nullable = true) + private String githubAccessToken; + @Builder private Member(String name, String email, String password, String profileImage, Role role) { this.name = name; @@ -61,4 +64,7 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol .build(); } + public void setGithubAccessToken(String token) { + this.githubAccessToken = token; + } } diff --git a/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java new file mode 100644 index 0000000..c197488 --- /dev/null +++ b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java @@ -0,0 +1,57 @@ +package com.whylog.server.global.util.github; + +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; +import com.whylog.server.domain.git.exception.RepositoryNotFoundException; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GitHub; +import org.kohsuke.github.GitHubBuilder; + +import java.io.IOException; + +@Slf4j +public class GitHubUtil { + + // GitHub URL에서 owner/repo 형식 추출 + public static String extractRepoPath(String repositoryUrl) { + String[] parts = repositoryUrl.replace("https://github.com/", "") + .replace(".git", "") + .split("/"); + if (parts.length >= 2) { + return parts[parts.length - 2] + "/" + parts[parts.length - 1]; + } + throw new ErrorHandler(GitErrorCode.INVALID_GITHUB_URL); + } + + + // Access Token으로 GitHub 인스턴스 생성 + public static GitHub createGitHubInstance(String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { + throw new GitTokenNotRegisteredException(); + } + try { + return new GitHubBuilder() + .withOAuthToken(accessToken) + .build(); + } catch (IOException e) { + log.error("GitHub 인스턴스 생성 실패: {}", e.getMessage()); + throw new GitTokenNotRegisteredException(); + } + } + + /** + * GitHub 레포지토리 존재 여부 검증 + */ + public static void validateRepositoryExists(String repositoryUrl, String accessToken) { + try { + GitHub gitHub = createGitHubInstance(accessToken); + String repoPath = extractRepoPath(repositoryUrl); + gitHub.getRepository(repoPath); + log.info("레포 검증 성공: {}", repoPath); + } catch (IOException e) { + log.error("레포 검증 실패: {}", e.getMessage()); + throw new RepositoryNotFoundException(); + } + } +} From 1b9600070768dc5b1274e17b8f3eee65caa084ae Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Wed, 8 Apr 2026 14:05:09 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20Commit=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=83=9D=EC=84=B1=20=EB=B0=A9=EC=8B=9D=20DTO=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/git/dto/GitRequest.java | 34 +++++++++++++++++ .../server/domain/git/entity/Commit.java | 38 +++++++------------ .../git/service/GitCommandServiceImpl.java | 25 ++++++------ 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java index 5f616e9..785b98a 100644 --- a/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java +++ b/src/main/java/com/whylog/server/domain/git/dto/GitRequest.java @@ -6,6 +6,8 @@ import lombok.*; import org.hibernate.validator.constraints.URL; +import java.time.LocalDateTime; + public class GitRequest { @Getter @@ -36,5 +38,37 @@ public static class GitHubTokenDTO { @JsonProperty("access_token") private String accessToken; } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "커밋 생성 DTO") + public static class CommitCreateDTO { + + @Schema(description = "커밋 해시", example = "abc123def456") + private String hash; + + @Schema(description = "커밋 메시지", example = "[feat] 사용자 인증 기능 추가") + private String message; + + @Schema(description = "작성자 이름", example = "김준용") + private String authorName; + + @Schema(description = "작성자 이메일", example = "user@example.com") + private String authorEmail; + + @Schema(description = "작성자 프로필 이미지", example = "https://avatars.githubusercontent.com/u/123?v=4") + private String authorProfileImage; + + @Schema(description = "커밋 날짜", example = "2026-03-24T10:30:00") + private LocalDateTime dateTime; + + @Schema(description = "추가된 줄 수", example = "45") + private Integer addedLines; + + @Schema(description = "삭제된 줄 수", example = "12") + private Integer deletedLines; + } } diff --git a/src/main/java/com/whylog/server/domain/git/entity/Commit.java b/src/main/java/com/whylog/server/domain/git/entity/Commit.java index 3dc77d9..65b7fc1 100644 --- a/src/main/java/com/whylog/server/domain/git/entity/Commit.java +++ b/src/main/java/com/whylog/server/domain/git/entity/Commit.java @@ -1,24 +1,14 @@ package com.whylog.server.domain.git.entity; +import com.whylog.server.domain.git.dto.GitRequest; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @Entity @Getter @Table(name = "Commits") @@ -67,18 +57,16 @@ public class Commit extends BaseEntity { // @OneToMany(mappedBy = "commit", cascade = CascadeType.ALL, orphanRemoval = true) // private final List commitConnections = new ArrayList<>(); - public static Commit create(String hash, String message, String authorName, String authorEmail, - String authorProfileImage, LocalDateTime dateTime, Integer addedLines, - Integer deletedLines, Repository repository) { + public static Commit create(GitRequest.CommitCreateDTO dto, Repository repository) { Commit commit = new Commit(); - commit.hash = hash; - commit.message = message; - commit.authorName = authorName; - commit.authorEmail = authorEmail; - commit.authorProfileImage = authorProfileImage; - commit.dateTime = dateTime; - commit.addedLines = addedLines; - commit.deletedLines = deletedLines; + commit.hash = dto.getHash(); + commit.message = dto.getMessage(); + commit.authorName = dto.getAuthorName(); + commit.authorEmail = dto.getAuthorEmail(); + commit.authorProfileImage = dto.getAuthorProfileImage(); + commit.dateTime = dto.getDateTime(); + commit.addedLines = dto.getAddedLines(); + commit.deletedLines = dto.getDeletedLines(); commit.repository = repository; return commit; } diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index a701e3c..492fccf 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -160,20 +160,21 @@ private void syncCommits(GHRepository ghRepository, Repository repository, Local ? ghCommit.getAuthor().getAvatarUrl() : null; - return Commit.create( - ghCommit.getSHA1(), - message, - shortInfo.getAuthor().getName(), - shortInfo.getAuthor().getEmail(), - authorProfileImage, - shortInfo.getAuthor().getDate() + GitRequest.CommitCreateDTO dto = GitRequest.CommitCreateDTO.builder() + .hash(ghCommit.getSHA1()) + .message(message) + .authorName(shortInfo.getAuthor().getName()) + .authorEmail(shortInfo.getAuthor().getEmail()) + .authorProfileImage(authorProfileImage) + .dateTime(shortInfo.getAuthor().getDate() .toInstant() .atZone(ZoneId.systemDefault()) - .toLocalDateTime(), - ghCommit.getLinesAdded(), - ghCommit.getLinesDeleted(), - repository - ); + .toLocalDateTime()) + .addedLines(ghCommit.getLinesAdded()) + .deletedLines(ghCommit.getLinesDeleted()) + .build(); + + return Commit.create(dto, repository); } catch (Exception e) { log.warn("커밋 변환 실패 [{}]: {}", ghCommit.getSHA1(), e.getMessage()); return null; From b6438aa4fac26231782ad792faf4b7be351dd9ed Mon Sep 17 00:00:00 2001 From: Yujin1219 Date: Sun, 12 Apr 2026 17:48:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20github=20accesstoken=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20401?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/git/exception/GitErrorCode.java | 6 +- .../domain/git/service/GitCommandService.java | 6 + .../git/service/GitCommandServiceImpl.java | 187 ++++++++++-------- .../server/domain/user/entity/Member.java | 21 +- .../util/crypto/AESCryptoConverter.java | 63 ++++++ .../server/global/util/github/GitHubUtil.java | 57 +++++- src/main/resources/application.yaml | 5 + 7 files changed, 251 insertions(+), 94 deletions(-) create mode 100644 src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java diff --git a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java index eccbf0a..02e1c10 100644 --- a/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java +++ b/src/main/java/com/whylog/server/domain/git/exception/GitErrorCode.java @@ -12,7 +12,11 @@ public enum GitErrorCode implements BaseErrorCode { REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_1", "존재하지 않는 레포지토리입니다."), COMMIT_NOT_FOUND(HttpStatus.NOT_FOUND, "GIT_404_2", "존재하지 않는 커밋입니다."), INVALID_GITHUB_URL(HttpStatus.BAD_REQUEST, "GIT_400_1", "유효하지 않은 GitHub URL입니다."), - GITHUB_TOKEN_NOT_REGISTERED(HttpStatus.BAD_REQUEST, "GIT_400_2", "GitHub Access Token이 등록되지 않았습니다."),; + GITHUB_TOKEN_NOT_REGISTERED(HttpStatus.BAD_REQUEST, "GIT_400_2", "GitHub Access Token이 등록되지 않았습니다."), + TOKEN_ENCRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "GIT_500_1", "GitHub 토큰 암호화 중 오류가 발생했습니다."), + TOKEN_DECRYPTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "GIT_500_2", "GitHub 토큰 복호화 중 오류가 발생했습니다."), + GITHUB_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "GIT_401_1", "GitHub Access Token이 만료되었습니다. 다시 인증해주세요."), + GITHUB_API_ERROR(HttpStatus.BAD_GATEWAY, "GIT_502_1", "GitHub API 호출 중 오류가 발생했습니다."),; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java index 519b532..10a1ad2 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandService.java @@ -20,4 +20,10 @@ public interface GitCommandService { * 레포지토리를 동기화합니다. */ GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long repositoryId); + + /** + * GitHub Token 만료 시 처리합니다 (API 401 에러 감지). + * token을 초기화하여 사용자가 재인증하도록 유도합니다. + */ + void invalidateGitHubToken(Long memberId); } diff --git a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java index 492fccf..97c991d 100644 --- a/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java +++ b/src/main/java/com/whylog/server/domain/git/service/GitCommandServiceImpl.java @@ -4,10 +4,12 @@ import com.whylog.server.domain.git.dto.GitResponse; import com.whylog.server.domain.git.entity.Commit; import com.whylog.server.domain.git.entity.Repository; +import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; import com.whylog.server.domain.git.exception.RepositoryNotFoundException; import com.whylog.server.domain.git.repository.CommitRepository; import com.whylog.server.domain.git.repository.RepositoryRepository; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import com.whylog.server.global.util.github.GitHubUtil; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; @@ -16,16 +18,17 @@ import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHRepository; import org.kohsuke.github.GitHub; +import org.kohsuke.github.HttpException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.List; -import java.util.Set; +import java.util.*; @Service @RequiredArgsConstructor @@ -39,12 +42,16 @@ public class GitCommandServiceImpl implements GitCommandService { /** * 사용자의 GitHub Access Token을 등록합니다. + * 등록 전에 token의 유효성을 검증합니다. */ @Override @Transactional public GitResponse.GitHubTokenResponseDTO registerGitHubToken(Long memberId, String accessToken) { if (accessToken == null || accessToken.isEmpty()) throw new ParameterRequiredException(); + // GitHub token 유효성 검증 (401 응답 시 예외 발생) + GitHubUtil.validateGitHubToken(accessToken); + Member member = memberUseCase.findMemberById(memberId); member.setGithubAccessToken(accessToken); @@ -63,10 +70,22 @@ public Repository createRepository(Long memberId, Long teamId, GitRequest.Reposi // 현재 사용자의 GitHub Token 조회 Member member = memberUseCase.findMemberById(memberId); - String accessToken = member.getGithubAccessToken(); - // 레포 URL 유효성 검증 및 GitHub에서 존재 여부 확인 - GitHubUtil.validateRepositoryExists(request.getUrl(), accessToken); + // token 존재 및 유효성 검증 + if (!member.hasGithubToken()) { + throw new GitTokenNotRegisteredException(); + } + + try { + GitHubUtil.validateGitHubToken(member.getGithubAccessToken()); + // 레포 URL 유효성 검증 및 GitHub에서 존재 여부 확인 + GitHubUtil.validateRepositoryExists(request.getUrl(), member.getGithubAccessToken()); + } catch (ErrorHandler e) { + if (e.getCode() == GitErrorCode.GITHUB_TOKEN_EXPIRED) { + invalidateGitHubToken(memberId); + } + throw e; + } Team team = teamUseCase.findTeamById(teamId); Repository repository = Repository.create(request.getName(), request.getUrl(), team); @@ -84,8 +103,7 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long // 사용자의 GitHub Token 조회 및 검증 Member member = memberUseCase.findMemberById(memberId); - String accessToken = member.getGithubAccessToken(); - if (accessToken == null || accessToken.isEmpty()) { + if (!member.hasGithubToken()) { throw new GitTokenNotRegisteredException(); } @@ -93,7 +111,7 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long .orElseThrow(RepositoryNotFoundException::new); try { - GitHub gitHub = GitHubUtil.createGitHubInstance(accessToken); + GitHub gitHub = GitHubUtil.createGitHubInstance(member.getGithubAccessToken()); String repoPath = GitHubUtil.extractRepoPath(repository.getUrl()); GHRepository ghRepository = gitHub.getRepository(repoPath); @@ -104,6 +122,12 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long // 동기화 시간 업데이트 repository.updateLastSyncedAt(LocalDateTime.now()); + } catch (HttpException e) { + if (e.getResponseCode() == 401) { + invalidateGitHubToken(memberId); // DB에서 토큰 삭제 + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + GitHubUtil.handleHttpException(e); } catch (IOException e) { throw new RepositoryNotFoundException(); } @@ -116,83 +140,80 @@ public GitResponse.RepositorySyncResponseDTO syncRepository(Long memberId, Long /** * GitHub에서 마지막 동기화 시간 이후의 커밋을 조회하고 DB에 일괄 저장합니다. */ - private void syncCommits(GHRepository ghRepository, Repository repository, LocalDateTime lastSyncedAt) { - try { - var commitQuery = ghRepository.queryCommits(); - - // 시간 필터링 - if (lastSyncedAt != null) { - java.util.Date sinceDate = java.util.Date.from( - lastSyncedAt.atZone(ZoneId.systemDefault()).toInstant() - ); - commitQuery.since(sinceDate); // 이 시간 이후의 커밋만 달라고 요청 - } + private void syncCommits(GHRepository ghRepository, Repository repository, LocalDateTime lastSyncedAt) throws IOException { - // GitHub에서 가져온 모든 커밋을 리스트로 변환 - List allGitHubCommits = commitQuery.list().toList(); - - // 모든 커밋의 hash를 추출 - List allHashes = allGitHubCommits.stream() - .map(org.kohsuke.github.GHCommit::getSHA1) - .toList(); - - // DB에 이미 존재하는 hash들을 조회 - Set existingHashes = allHashes.isEmpty() - ? java.util.Collections.emptySet() - : commitRepository.findExistingHashes(repository.getId(), allHashes); - - //필요한 커밋만 필터링 및 변환 - List newCommits = allGitHubCommits.stream() - .filter(ghCommit -> !existingHashes.contains(ghCommit.getSHA1())) - .map(ghCommit -> { - try { - // 커밋 정보를 한 번에 조회 - var shortInfo = ghCommit.getCommitShortInfo(); - String message = shortInfo.getMessage(); - - // Merge pull request 커밋 제외 - if (message.startsWith("Merge pull request")) { - return null; - } - - // 작성자 프로필 이미지 URL 조회 - String authorProfileImage = (ghCommit.getAuthor() != null) - ? ghCommit.getAuthor().getAvatarUrl() - : null; - - GitRequest.CommitCreateDTO dto = GitRequest.CommitCreateDTO.builder() - .hash(ghCommit.getSHA1()) - .message(message) - .authorName(shortInfo.getAuthor().getName()) - .authorEmail(shortInfo.getAuthor().getEmail()) - .authorProfileImage(authorProfileImage) - .dateTime(shortInfo.getAuthor().getDate() - .toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime()) - .addedLines(ghCommit.getLinesAdded()) - .deletedLines(ghCommit.getLinesDeleted()) - .build(); - - return Commit.create(dto, repository); - } catch (Exception e) { - log.warn("커밋 변환 실패 [{}]: {}", ghCommit.getSHA1(), e.getMessage()); - return null; - } - }) - .filter(java.util.Objects::nonNull) - .toList(); - - // db insert - if (!newCommits.isEmpty()) { - commitRepository.saveAll(newCommits); - log.info("새로운 커밋 {}개 동기화 완료: {}", newCommits.size(), ghRepository.getFullName()); - } else { - log.info("새로 동기화할 커밋이 없습니다: {}", ghRepository.getFullName()); - } + var commitQuery = ghRepository.queryCommits(); - } catch (IOException e) { - throw new RepositoryNotFoundException(); + if (lastSyncedAt != null) { + Date sinceDate = Date.from( + lastSyncedAt.atZone(ZoneId.systemDefault()).toInstant() + ); + commitQuery.since(sinceDate); + } + + List allGitHubCommits = commitQuery.list().toList(); + + List allHashes = allGitHubCommits.stream() + .map(GHCommit::getSHA1) + .toList(); + + Set existingHashes = allHashes.isEmpty() + ? Collections.emptySet() + : commitRepository.findExistingHashes(repository.getId(), allHashes); + + List newCommits = allGitHubCommits.stream() + .filter(ghCommit -> !existingHashes.contains(ghCommit.getSHA1())) + .map(ghCommit -> { + try { + var shortInfo = ghCommit.getCommitShortInfo(); + String message = shortInfo.getMessage(); + + if (message.startsWith("Merge pull request")) return null; + + String authorProfileImage = (ghCommit.getAuthor() != null) + ? ghCommit.getAuthor().getAvatarUrl() : null; + + GitRequest.CommitCreateDTO dto = GitRequest.CommitCreateDTO.builder() + .hash(ghCommit.getSHA1()) + .message(message) + .authorName(shortInfo.getAuthor().getName()) + .authorEmail(shortInfo.getAuthor().getEmail()) + .authorProfileImage(authorProfileImage) + .dateTime(shortInfo.getAuthor().getDate() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime()) + .addedLines(ghCommit.getLinesAdded()) + .deletedLines(ghCommit.getLinesDeleted()) + .build(); + + return Commit.create(dto, repository); + } catch (Exception e) { + log.warn("커밋 변환 실패 [{}]: {}", ghCommit.getSHA1(), e.getMessage()); + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + + if (!newCommits.isEmpty()) { + commitRepository.saveAll(newCommits); + log.info("새로운 커밋 {}개 동기화 완료: {}", newCommits.size(), ghRepository.getFullName()); + } else { + log.info("새로 동기화할 커밋이 없습니다: {}", ghRepository.getFullName()); } } + + /** + * GitHub Token 만료 시 처리합니다 (API 401 에러 감지) + * token을 초기화하여 사용자가 재인증하도록 유도합니다. + */ + @Override + @Transactional + public void invalidateGitHubToken(Long memberId) { + if (memberId == null) throw new ParameterRequiredException(); + + Member member = memberUseCase.findMemberById(memberId); + member.clearGithubToken(); + } } diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index e42a4c4..45ce284 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -3,14 +3,8 @@ import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import com.whylog.server.global.util.crypto.AESCryptoConverter; +import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -43,6 +37,7 @@ public class Member extends BaseEntity { @Column(nullable = false, length = 20) private Role role; + @Convert(converter = AESCryptoConverter.class) @Column(name = "github_access_token", length = 500, nullable = true) private String githubAccessToken; @@ -67,4 +62,14 @@ public static Member create(AuthRequest.SignUpDTO dto, String password, Role rol public void setGithubAccessToken(String token) { this.githubAccessToken = token; } + + // 토큰 존재 확인 + public boolean hasGithubToken() { + return this.githubAccessToken != null && !this.githubAccessToken.isEmpty(); + } + + // 401 에러시 토큰 삭제 + public void clearGithubToken() { + this.githubAccessToken = null; + } } diff --git a/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java b/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java new file mode 100644 index 0000000..ed8a329 --- /dev/null +++ b/src/main/java/com/whylog/server/global/util/crypto/AESCryptoConverter.java @@ -0,0 +1,63 @@ +package com.whylog.server.global.util.crypto; + +import com.whylog.server.domain.git.exception.GitErrorCode; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Converter +@Component +public class AESCryptoConverter implements AttributeConverter { + + private static final String ALGORITHM = "AES"; + + @Value("${github.token.encryption.key}") + private String secretKey; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) return null; + + try { + validateKey(); + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + + byte[] encryptedBytes = cipher.doFinal(attribute.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encryptedBytes); + } catch (Exception e) { + throw new ErrorHandler(GitErrorCode.TOKEN_ENCRYPTION_FAILED); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + + try { + validateKey(); + Cipher cipher = Cipher.getInstance(ALGORITHM); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + + byte[] decodedBytes = Base64.getDecoder().decode(dbData); + return new String(cipher.doFinal(decodedBytes), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new ErrorHandler(GitErrorCode.TOKEN_DECRYPTION_FAILED); + } + } + + private void validateKey() { + if (secretKey == null || secretKey.length() != 32) { + throw new ErrorHandler(GitErrorCode.TOKEN_ENCRYPTION_FAILED); // 키 설정 오류도 암호화 에러로 처리 + } + } +} \ No newline at end of file diff --git a/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java index c197488..abfd161 100644 --- a/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java +++ b/src/main/java/com/whylog/server/global/util/github/GitHubUtil.java @@ -2,11 +2,11 @@ import com.whylog.server.domain.git.exception.GitErrorCode; import com.whylog.server.domain.git.exception.GitTokenNotRegisteredException; -import com.whylog.server.domain.git.exception.RepositoryNotFoundException; import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; import lombok.extern.slf4j.Slf4j; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; +import org.kohsuke.github.HttpException; import java.io.IOException; @@ -42,6 +42,7 @@ public static GitHub createGitHubInstance(String accessToken) { /** * GitHub 레포지토리 존재 여부 검증 + * (token 유효성은 호출 전에 validateGitHubToken()으로 먼저 확인되어야 함) */ public static void validateRepositoryExists(String repositoryUrl, String accessToken) { try { @@ -49,9 +50,61 @@ public static void validateRepositoryExists(String repositoryUrl, String accessT String repoPath = extractRepoPath(repositoryUrl); gitHub.getRepository(repoPath); log.info("레포 검증 성공: {}", repoPath); + } catch (HttpException e) { + if (e.getResponseCode() == 404) { + log.warn("레포지토리를 찾을 수 없음: {}", repositoryUrl); + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_FOUND); + } + log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); } catch (IOException e) { log.error("레포 검증 실패: {}", e.getMessage()); - throw new RepositoryNotFoundException(); + throw new ErrorHandler(GitErrorCode.REPOSITORY_NOT_FOUND); } } + + /** + * GitHub Access Token의 유효성을 검증합니다. + * 사용자 정보 조회를 시도하여 token이 유효한지 확인합니다. + * + * @param accessToken 검증할 GitHub access token + * @throws ErrorHandler token이 유효하지 않으면 GITHUB_TOKEN_EXPIRED 예외 발생 + */ + public static void validateGitHubToken(String accessToken) { + try { + GitHub gitHub = createGitHubInstance(accessToken); + // 간단한 API 호출로 token 유효성 확인 (사용자 정보 조회) + gitHub.getMyself(); + log.info("GitHub token 유효성 검증 성공"); + } catch (HttpException e) { + // 401 Unauthorized: token 만료 또는 유효하지 않음 + if (e.getResponseCode() == 401) { + log.warn("GitHub token 유효하지 않음 (401 Unauthorized)"); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + // 다른 HTTP 에러 + log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); + } catch (IOException e) { + log.error("GitHub token 검증 중 네트워크 에러: {}", e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + } + + /** + * GitHub API 호출 후 HttpException 에러를 처리합니다. + * (동기화 작업 중 발생하는 에러를 통합 처리) + * + * @param e HttpException + * @throws ErrorHandler 적절한 에러 코드와 함께 예외 발생 + */ + public static void handleHttpException(HttpException e) { + if (e.getResponseCode() == 401) { + log.warn("GitHub API 401 Unauthorized - token 만료"); + throw new ErrorHandler(GitErrorCode.GITHUB_TOKEN_EXPIRED); + } + // 다른 HTTP 에러 + log.error("GitHub API 에러 (상태코드: {}): {}", e.getResponseCode(), e.getMessage()); + throw new ErrorHandler(GitErrorCode.GITHUB_API_ERROR); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ee8d47f..f553e00 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -50,3 +50,8 @@ livekit: api-key: ${LIVEKIT_API_KEY:devkey} api-secret: ${LIVEKIT_API_SECRET:01234567890123456789012345678901} token-expire-time: ${LIVEKIT_TOKEN_EXPIRE_TIME:3600000} + +github: + token: + encryption: + key: ${GITHUB_TOKEN_ENCRYPTION_KEY} \ No newline at end of file