diff --git a/.github/workflows/CD.yml b/.github/workflows/CD.yml new file mode 100644 index 0000000..64ac456 --- /dev/null +++ b/.github/workflows/CD.yml @@ -0,0 +1,135 @@ +name: CD + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + # 코드 체크아웃 + - name: Checkout + uses: actions/checkout@v4 + + # DockerHub 로그인 + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # Docker 이미지 빌드 & 푸시 + - name: Build and Push Docker image + run: | + docker build -t ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} . + docker push ${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }} + + # EC2로 yaml 파일 복사 + - name: Copy k8s manifests to EC2 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "k8s/*.yaml" + target: "/home/${{ secrets.EC2_USER }}/k8s-manifests" + + # SSH 접속 후 app.yaml의 IMAGE 치환 + - name: Replace IMAGE in app.yaml on EC2 + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # k8s 매니페스트 디렉토리 + MANIFEST_DIR="/home/${{ secrets.EC2_USER }}/k8s-manifests/k8s" + + # IMAGE를 GitHub Secret 값으로 치환 + sed -i "s|IMAGE|${{ secrets.DOCKER_USERNAME }}/skill-boost:${{ github.sha }}|g" "$MANIFEST_DIR/app.yaml" + + echo "✅ app.yaml의 USERNAME 치환 완료" + +# # Github Actions IP 가져오기 +# - name: Get Github Actions IP +# id: ip +# uses: haythem/public-ip@v1.2 +# +# # AWS 보안 그룹에 동적으로 IP 추가 +# - name: Add Github Actions IP to Security group +# run: | +# aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SECRET_GROUP_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 +# env: +# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} +# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} +# AWS_DEFAULT_REGION: ap-northeast-2 + + # EC2 SSH → k3s에 Secret 생성 & Deployment 업데이트 + - name: Deploy to K3s via SSH + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + # 기존 db-secret 삭제 + kubectl delete secret db-secret + + # -- db-secret 생성 -- + cat < questions = new ArrayList<>(); + + public CodeReviewResponse() { + } + + public CodeReviewResponse(String review, List questions) { + this.review = review; + this.questions = questions; + } + + public String getReview() { + return review; + } + + public void setReview(String review) { + this.review = review; + } + + public List getQuestions() { + return questions; + } + + public void setQuestions(List questions) { + this.questions = questions; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java new file mode 100644 index 0000000..af2b4a3 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java @@ -0,0 +1,193 @@ +package com.example.skillboost.codereview.client; + +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +@Component +public class GeminiCodeReviewClient { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String apiKey; + private final String model; + + public GeminiCodeReviewClient( + @Value("${gemini.api.key}") String apiKey, + @Value("${gemini.model}") String model + ) { + this.apiKey = apiKey; + this.model = model; + this.restTemplate = new RestTemplate(); + this.objectMapper = new ObjectMapper(); + } + + public CodeReviewResponse requestReview(String code, String comment) { + try { + String url = "https://generativelanguage.googleapis.com/v1beta/models/" + + model + ":generateContent?key=" + apiKey; + + String prompt = buildPrompt(code, comment); + + Map textPart = new HashMap<>(); + textPart.put("text", prompt); + + Map content = new HashMap<>(); + content.put("parts", Collections.singletonList(textPart)); + + Map requestBody = new HashMap<>(); + requestBody.put("contents", Collections.singletonList(content)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); + String body = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful() || body == null) { + CodeReviewResponse fallback = new CodeReviewResponse(); + fallback.setReview("AI 코드 리뷰 요청에 실패했습니다. 상태코드: " + response.getStatusCode()); + fallback.setQuestions(Collections.emptyList()); + return fallback; + } + + return parseGeminiResponse(body); + + } catch (Exception e) { + CodeReviewResponse fallback = new CodeReviewResponse(); + fallback.setReview("AI 코드 리뷰 중 오류가 발생했습니다: " + e.getMessage()); + fallback.setQuestions(Collections.emptyList()); + return fallback; + } + } + + /** + * 리뷰 + 질문을 "간결하고 핵심적"이고 "□ 포맷", "1. 2. 질문 구조"로 내보내도록 만드는 프롬프트 + */ + private String buildPrompt(String code, String comment) { + String userRequirement = (comment != null && !comment.trim().isEmpty()) + ? comment.trim() + : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; + + return """ + 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. + 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. + + 🔒 출력 형식 규칙 + - JSON만 출력 (바깥에 설명 절대 금지) + - 마크다운 금지(**, ```, # 등) + - review 항목은: + - 모든 줄을 '□ ' 로 시작 + - 한 줄은 핵심 한 문장만 + - 항목 사이에는 빈 줄(\\n\\n)을 넣기 + - questions 항목은: + - 배열 형태 + - 각 질문은 한 문장 + - 번호(1. 2.)는 넣지 말 것 (번호는 프론트에서 자동 생성됨) + + JSON 예시: + + { + "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", + "questions": [ + "이 코드의 시간 복잡도는 왜 O(N)인가요?", + "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" + ] + } + + 사용자가 요청한 요구사항: + %s + + 리뷰할 코드: + %s + """.formatted(userRequirement, code); + } + + /** + * Gemini 응답(JSON 스트링)을 CodeReviewResponse로 변환 + */ + private CodeReviewResponse parseGeminiResponse(String body) throws Exception { + JsonNode root = objectMapper.readTree(body); + + JsonNode candidates = root.path("candidates"); + if (!candidates.isArray() || candidates.isEmpty()) { + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview("AI 응답이 비어 있습니다."); + resp.setQuestions(Collections.emptyList()); + return resp; + } + + JsonNode textNode = candidates.get(0) + .path("content") + .path("parts") + .get(0) + .path("text"); + + String rawText = textNode.asText(""); + if (rawText.isEmpty()) { + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview("AI 응답 텍스트를 찾지 못했습니다."); + resp.setQuestions(Collections.emptyList()); + return resp; + } + + // ```json ... ``` 형태 제거 + String cleaned = stripCodeFence(rawText); + + // JSON 파싱 + try { + JsonNode json = objectMapper.readTree(cleaned); + + String review = json.path("review").asText(""); + if (review.isEmpty()) review = cleaned; + + List questions = new ArrayList<>(); + JsonNode qNode = json.path("questions"); + if (qNode.isArray()) { + for (JsonNode q : qNode) questions.add(q.asText()); + } + + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview(review); + resp.setQuestions(questions); + return resp; + + } catch (Exception e) { + // JSON 파싱 실패 시 그대로 리뷰로 전달 + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview(cleaned); + resp.setQuestions(Collections.emptyList()); + return resp; + } + } + + /** + * ```json + * {...} + * ``` + * 같은 코드블럭 제거 + */ + private String stripCodeFence(String text) { + if (text == null) return ""; + String trimmed = text.trim(); + + if (!trimmed.startsWith("```")) return trimmed; + + int firstNewline = trimmed.indexOf('\n'); + int lastFence = trimmed.lastIndexOf("```"); + + if (firstNewline != -1 && lastFence != -1 && lastFence > firstNewline) { + return trimmed.substring(firstNewline + 1, lastFence).trim(); + } + + return trimmed; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java new file mode 100644 index 0000000..c8eb152 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java @@ -0,0 +1,9 @@ +package com.example.skillboost.codereview.service; + +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; + +public interface CodeReviewService { + + CodeReviewResponse review(CodeReviewRequest request); +} diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java new file mode 100644 index 0000000..6635aed --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java @@ -0,0 +1,27 @@ +package com.example.skillboost.codereview.service; + +import com.example.skillboost.codereview.client.GeminiCodeReviewClient; +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class CodeReviewServiceImpl implements CodeReviewService { + + private final GeminiCodeReviewClient geminiCodeReviewClient; + + @Override + public CodeReviewResponse review(CodeReviewRequest request) { + if (request == null || !StringUtils.hasText(request.getCode())) { + throw new IllegalArgumentException("코드가 비어 있습니다."); + } + + String code = request.getCode(); + String comment = request.getComment(); + + return geminiCodeReviewClient.requestReview(code, comment); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java b/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java new file mode 100644 index 0000000..fdaaa0d --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java @@ -0,0 +1,54 @@ +package com.example.skillboost.codingtest.controller; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Random; + +@RestController +@RequestMapping("/api/coding/problems") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class CodingTestController { + + private final CodingProblemRepository problemRepository; + + @GetMapping("/random") + public ResponseEntity getRandomProblem(@RequestParam(required = false) String difficulty) { + List problems; + + // 1. 프론트에서 난이도를 선택했는지 확인 + if (difficulty != null && !difficulty.isEmpty()) { + try { + // "EASY" -> Difficulty.EASY 변환 + Difficulty diff = Difficulty.valueOf(difficulty.toUpperCase()); + // 해당 난이도 문제들만 DB에서 가져옴 (예: 5개) + problems = problemRepository.findAllByDifficulty(diff); + } catch (IllegalArgumentException e) { + // 이상한 난이도가 오면 그냥 전체 문제 가져옴 + problems = problemRepository.findAll(); + } + } else { + // 난이도 선택 안 했으면 전체 문제(15개) 가져옴 + problems = problemRepository.findAll(); + } + + // 2. 문제가 하나도 없으면 404 에러 + if (problems.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + // 3. 목록 중에서 랜덤으로 하나 뽑기 (핵심 로직) + Random random = new Random(); + int randomIndex = random.nextInt(problems.size()); // 0 ~ (개수-1) 사이 랜덤 숫자 + CodingProblem randomProblem = problems.get(randomIndex); + + // 4. 뽑힌 문제 반환 + return ResponseEntity.ok(randomProblem); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java new file mode 100644 index 0000000..df2ee8c --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -0,0 +1,39 @@ +package com.example.skillboost.codingtest.controller; + +import com.example.skillboost.codingtest.dto.SubmissionRequestDto; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.example.skillboost.codingtest.service.GradingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/coding") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class SubmissionController { + + private final GradingService gradingService; + + @PostMapping("/submissions") + public ResponseEntity submitCode(@RequestBody SubmissionRequestDto request) { + + log.info("채점 요청 도착: problemId={}, language={}", + request.getProblemId(), request.getLanguage()); + + if (request.getCode() == null || request.getCode().isEmpty()) { + return ResponseEntity.badRequest().body( + SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("코드가 비어 있습니다.") + .build() + ); + } + + SubmissionResultDto result = gradingService.grade(request); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java new file mode 100644 index 0000000..a5a7bce --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java @@ -0,0 +1,34 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingProblem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + @Column(columnDefinition = "TEXT") // 긴 문제 설명 저장용 + private String description; + + @Enumerated(EnumType.STRING) + private Difficulty difficulty; + + // 예: "array,implementation" + private String tags; + + @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) + private List testCases = new ArrayList<>(); +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java new file mode 100644 index 0000000..a64dff4 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java @@ -0,0 +1,41 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + private CodingProblem problem; + + // ★ [추가] 누가 풀었는지 저장해야 합니다! + private Long userId; + + private String language; + + @Column(columnDefinition = "TEXT") + private String sourceCode; + + private String verdict; + private int passedCount; + private int totalCount; + + private LocalDateTime createdAt; + + @PrePersist + public void onCreate() { + this.createdAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java new file mode 100644 index 0000000..e81bb41 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java @@ -0,0 +1,30 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CodingTestCase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ★ 여기 필드 이름이 CodingProblem의 mappedBy("problem") 와 같아야 함 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id") + private CodingProblem problem; + + @Column(columnDefinition = "TEXT") + private String inputData; + + @Column(columnDefinition = "TEXT") + private String expectedOutput; + + private boolean sample; // 예제용 테스트케이스인지 여부 +} diff --git a/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java b/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java new file mode 100644 index 0000000..9110aeb --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java @@ -0,0 +1,7 @@ +package com.example.skillboost.codingtest.domain; + +public enum Difficulty { + EASY, + MEDIUM, + HARD +} diff --git a/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java b/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java new file mode 100644 index 0000000..d00ecb9 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java @@ -0,0 +1,23 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.Builder; +import lombok.Data; +import java.util.List; + +@Data +@Builder +public class ProblemDetailDto { + private Long id; + private String title; + private String description; + private String difficulty; + private String tags; + private List samples; + + @Data + @Builder + public static class SampleCase { + private String inputData; + private String expectedOutput; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java b/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java new file mode 100644 index 0000000..6445c1e --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java @@ -0,0 +1,13 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class ProblemSummaryDto { + private Long id; + private String title; + private String difficulty; + private String tags; +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java new file mode 100644 index 0000000..c2c6149 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java @@ -0,0 +1,20 @@ +package com.example.skillboost.codingtest.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SubmissionRequestDto { + + private Long problemId; + + // 프론트에서 보내는 JSON 키: "sourceCode" + @JsonProperty("sourceCode") + private String code; + + private String language; + + private Long userId; +} diff --git a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java new file mode 100644 index 0000000..b1f67bf --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java @@ -0,0 +1,22 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionResultDto { + private Long submissionId; + private String status; // "AC"(정답), "WA"(오답) + private Integer score; // 0 ~ 100점 + private Integer passedCount; // (AI 추정치) + private Integer totalCount; + private String message; // "정답입니다!" 같은 간단 메시지 + + // ★ [추가] AI 선생님의 상세 피드백 + private String aiFeedback; +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java new file mode 100644 index 0000000..b20d889 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java @@ -0,0 +1,777 @@ +package com.example.skillboost.codingtest.init; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CodingTestDataInitializer implements CommandLineRunner { + + private final CodingProblemRepository problemRepository; + + @Override + public void run(String... args) { + // EASY (5문제) + createExamSupervisorProblem(); // 시험 감독 + createZoacDistancingProblem(); // ZOAC 거리두기 + createDjmaxRankingProblem(); // DJMAX 랭킹 + createMinHeapProblem(); // 최소 힙 + createTriangleProblem(); // 삼각형 분류 + + // MEDIUM (5문제) + createSnakeGameProblem(); // Dummy (뱀 게임) + createDiceSimulationProblem(); // 주사위 굴리기 + createTargetDistanceProblem(); // 목표지점 거리 + createDfsBfsProblem(); // DFS와 BFS + createTripPlanningProblem(); // 여행 가자 (New) + + // HARD (5문제) + createMarbleEscapeProblem(); // 구슬 탈출 + createSharkCopyMagicProblem(); // 마법사 상어와 복제 + createSimilarWordsProblem(); // 비슷한 단어 + createJewelThiefProblem(); // 보석 도둑 + createMarsExplorationProblem(); // 화성 탐사 (New) + } + + // ========================= + // EASY 문제들 + // ========================= + + // 1. 시험 감독 + private void createExamSupervisorProblem() { + if (problemRepository.existsByTitle("시험 감독")) { + return; + } + + String description = """ + [문제] + + 총 N개의 시험장이 있고, 각각의 시험장마다 응시자들이 있다. i번 시험장에 있는 응시자의 수는 Ai명이다. + + 감독관은 총감독관과 부감독관으로 두 종류가 있다. + 총감독관은 한 시험장에서 감시할 수 있는 응시자의 수가 B명이고, + 부감독관은 한 시험장에서 감시할 수 있는 응시자의 수가 C명이다. + + 각각의 시험장에 총감독관은 오직 1명만 있어야 하고, + 부감독관은 여러 명 있어도 된다. + + 각 시험장마다 응시생들을 모두 감시해야 한다. + 이때, 필요한 감독관 수의 최솟값을 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 시험장의 개수 N(1 ≤ N ≤ 1,000,000)이 주어진다. + 둘째 줄에는 각 시험장에 있는 응시자의 수 Ai (1 ≤ Ai ≤ 1,000,000)가 주어진다. + 셋째 줄에는 B와 C가 주어진다. (1 ≤ B, C ≤ 1,000,000) + + + [출력] + + 각 시험장마다 응시생을 모두 감독하기 위해 필요한 감독관의 최소 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("시험 감독") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,greedy") + .build(); + + problemRepository.save(problem); + } + + // 2. ZOAC 거리두기 + private void createZoacDistancingProblem() { + if (problemRepository.existsByTitle("ZOAC 거리두기")) { + return; + } + + String description = """ + [문제] + + 2021년 12월, 네 번째로 개최된 ZOAC의 오프닝을 맡은 성우는 + 오프라인 대회를 대비하여 강의실을 예약하려고 한다. + + 강의실에서 대회를 치르려면 거리두기 수칙을 지켜야 한다! + + 한 명씩 앉을 수 있는 테이블이 행마다 W개씩 H행에 걸쳐 있을 때, + 모든 참가자는 세로로 N칸 또는 가로로 M칸 이상 비우고 앉아야 한다. + 즉, 다른 모든 참가자와 세로줄 번호의 차가 N보다 크거나 + 가로줄 번호의 차가 M보다 큰 곳에만 앉을 수 있다. + + 논문과 과제에 시달리는 성우를 위해 + 강의실이 거리두기 수칙을 지키면서 + 최대 몇 명을 수용할 수 있는지 구해보자. + + + [입력] + + H, W, N, M이 공백으로 구분되어 주어진다. + (0 < H, W, N, M ≤ 50,000) + + + [출력] + + 강의실이 수용할 수 있는 최대 인원 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("ZOAC 거리두기") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,implementation") + .build(); + + problemRepository.save(problem); + } + + // 3. DJMAX 랭킹 + private void createDjmaxRankingProblem() { + if (problemRepository.existsByTitle("DJMAX 랭킹")) { + return; + } + + String description = """ + [문제] + + 태수가 즐겨하는 디제이맥스 게임은 각각의 노래마다 랭킹 리스트가 있다. + 이것은 매번 게임할 때마다 얻는 점수가 비오름차순으로 저장되어 있는 것이다. + + 이 랭킹 리스트의 등수는 보통 위에서부터 몇 번째 있는 점수인지로 결정한다. + 하지만, 같은 점수가 있을 때는 그러한 점수의 등수 중에 가장 작은 등수가 된다. + + 예를 들어 랭킹 리스트가 100, 90, 90, 80일 때 각각의 등수는 1, 2, 2, 4등이 된다. + + 랭킹 리스트에 올라 갈 수 있는 점수의 개수 P가 주어진다. + 그리고 리스트에 있는 점수 N개가 비오름차순으로 주어지고, + 태수의 새로운 점수가 주어진다. + 이때, 태수의 새로운 점수가 랭킹 리스트에서 몇 등 하는지 구하는 프로그램을 작성하시오. + 만약 점수가 랭킹 리스트에 올라갈 수 없을 정도로 낮다면 -1을 출력한다. + + 만약, 랭킹 리스트가 꽉 차있을 때, + 새 점수가 이전 점수보다 더 좋을 때만 점수가 바뀐다. + + + [입력] + + 첫째 줄에 N, 태수의 새로운 점수, 그리고 P가 주어진다. + P는 10보다 크거나 같고, 50보다 작거나 같은 정수, + N은 0보다 크거나 같고, P보다 작거나 같은 정수이다. + 그리고 모든 점수는 2,000,000,000보다 작거나 같은 자연수 또는 0이다. + + 둘째 줄에는 현재 랭킹 리스트에 있는 점수가 비오름차순으로 주어진다. + 둘째 줄은 N이 0보다 큰 경우에만 주어진다. + + + [출력] + + 첫째 줄에 태수의 점수가 랭킹 리스트에서 차지하는 등수를 출력한다. + 랭킹 리스트에 올라갈 수 없으면 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("DJMAX 랭킹") + .difficulty(Difficulty.EASY) + .description(description) + .tags("implementation,sorting") + .build(); + + problemRepository.save(problem); + } + + // 4. 최소 힙 + private void createMinHeapProblem() { + if (problemRepository.existsByTitle("최소 힙")) { + return; + } + + String description = """ + [문제] + + 널리 잘 알려진 자료구조 중 최소 힙이 있다. + 최소 힙을 이용하여 다음과 같은 연산을 지원하는 프로그램을 작성하시오. + + 1. 배열에 자연수 x를 넣는다. + 2. 배열에서 가장 작은 값을 출력하고, 그 값을 배열에서 제거한다. + + 프로그램은 처음에 비어있는 배열에서 시작하게 된다. + + + [입력] + + 첫째 줄에 연산의 개수 N(1 ≤ N ≤ 100,000)이 주어진다. + 다음 N개의 줄에는 연산에 대한 정보를 나타내는 정수 x가 주어진다. + 만약 x가 자연수라면 배열에 x라는 값을 넣는(추가하는) 연산이고, + x가 0이라면 배열에서 가장 작은 값을 출력하고 그 값을 배열에서 제거하는 경우이다. + x는 2^31보다 작은 자연수 또는 0이고, 음의 정수는 입력으로 주어지지 않는다. + + + [출력] + + 입력에서 0이 주어진 횟수만큼 답을 출력한다. + 만약 배열이 비어 있는 경우인데 가장 작은 값을 출력하라고 한 경우에는 0을 출력하면 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("최소 힙") + .difficulty(Difficulty.EASY) + .description(description) + .tags("datastructure,heap") + .build(); + + problemRepository.save(problem); + } + + // 5. 삼각형 분류 + private void createTriangleProblem() { + if (problemRepository.existsByTitle("삼각형 분류")) { + return; + } + + String description = """ + [문제] + + 삼각형의 세 변의 길이가 주어질 때 변의 길이에 따라 다음과 같이 정의한다. + Equilateral : 세 변의 길이가 모두 같은 경우 + Isosceles : 두 변의 길이만 같은 경우 + Scalene : 세 변의 길이가 모두 다른 경우 + + 단 주어진 세 변의 길이가 삼각형의 조건을 만족하지 못하는 경우에는 "Invalid" 를 출력한다. + 예를 들어 6, 3, 2가 이 경우에 해당한다. + 가장 긴 변의 길이보다 나머지 두 변의 길이의 합이 길지 않으면 삼각형의 조건을 만족하지 못한다. + + 세 변의 길이가 주어질 때 위 정의에 따른 결과를 출력하시오. + + + [입력] + + 각 줄에는 1,000을 넘지 않는 양의 정수 3개가 입력된다. + 마지막 줄은 0 0 0이며 이 줄은 계산하지 않는다. + + + [출력] + + 각 입력에 대해 Equilateral, Isosceles, Scalene, Invalid 중 하나를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("삼각형 분류") + .difficulty(Difficulty.EASY) + .description(description) + .tags("math,implementation,geometry") + .build(); + + problemRepository.save(problem); + } + + // ========================= + // MEDIUM 문제들 + // ========================= + + // 6. Dummy (뱀 게임) + private void createSnakeGameProblem() { + if (problemRepository.existsByTitle("Dummy (뱀 게임)")) { + return; + } + + String description = """ + [문제] + + 'Dummy' 라는 도스게임이 있다. 이 게임에는 뱀이 나와서 기어다니는데, + 사과를 먹으면 뱀 길이가 늘어난다. + 뱀이 이리저리 기어다니다가 벽 또는 자기자신의 몸과 부딪히면 게임이 끝난다. + + 게임은 NxN 정사각 보드 위에서 진행되고, 몇몇 칸에는 사과가 놓여져 있다. + 보드의 상하좌우 끝에는 벽이 있다. + 게임이 시작할 때 뱀은 맨 위 맨 좌측에 위치하고 뱀의 길이는 1이다. + 뱀은 처음에 오른쪽을 향한다. + + 뱀은 매 초마다 이동을 하는데 다음과 같은 규칙을 따른다. + + 1. 먼저 뱀은 몸길이를 늘려 머리를 다음 칸에 위치시킨다. + 2. 만약 벽이나 자기자신의 몸과 부딪히면 게임이 끝난다. + 3. 만약 이동한 칸에 사과가 있다면, 그 칸에 있던 사과가 없어지고 꼬리는 움직이지 않는다. + 4. 만약 이동한 칸에 사과가 없다면, 몸길이를 줄여서 꼬리가 위치한 칸을 비워준다. 즉, 몸길이는 변하지 않는다. + + 사과의 위치와 뱀의 이동경로가 주어질 때 + 이 게임이 몇 초에 끝나는지 계산하라. + + + [입력] + + 첫째 줄에 보드의 크기 N이 주어진다. (2 ≤ N ≤ 100) + 다음 줄에 사과의 개수 K가 주어진다. (0 ≤ K ≤ 100) + + 다음 K개의 줄에는 사과의 위치가 주어진다. + 첫 번째 정수는 행, 두 번째 정수는 열 위치를 의미한다. + 사과의 위치는 모두 다르며, 맨 위 맨 좌측 (1행 1열)에는 사과가 없다. + + 다음 줄에는 뱀의 방향 변환 횟수 L이 주어진다. (1 ≤ L ≤ 100) + + 다음 L개의 줄에는 뱀의 방향 변환 정보가 주어진다. + 정수 X와 문자 C로 이루어져 있으며, + 게임 시작 시간으로부터 X초가 끝난 뒤에 + 왼쪽(C가 'L') 또는 오른쪽(C가 'D')으로 90도 방향을 회전시킨다는 뜻이다. + X는 10,000 이하의 양의 정수이며, 방향 전환 정보는 X가 증가하는 순으로 주어진다. + + + [출력] + + 첫째 줄에 게임이 몇 초에 끝나는지 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("Dummy (뱀 게임)") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("simulation,implementation,queue") + .build(); + + problemRepository.save(problem); + } + + // 7. 주사위 굴리기 + private void createDiceSimulationProblem() { + if (problemRepository.existsByTitle("주사위 굴리기")) { + return; + } + + String description = """ + [문제] + + 크기가 N×M인 지도가 존재한다. 지도의 오른쪽은 동쪽, 위쪽은 북쪽이다. + 이 지도의 위에 주사위가 하나 놓여져 있으며, 주사위의 전개도는 아래와 같다. + 지도의 좌표는 (r, c)로 나타내며, r는 북쪽으로부터 떨어진 칸의 개수, + c는 서쪽으로부터 떨어진 칸의 개수이다. + + 2 + 4 1 3 + 5 + 6 + + 주사위는 지도 위에 윗 면이 1이고, 동쪽을 바라보는 방향이 3인 상태로 놓여져 있으며, + 놓여져 있는 곳의 좌표는 (x, y)이다. + 가장 처음에 주사위에는 모든 면에 0이 적혀져 있다. + + 지도의 각 칸에는 정수가 하나씩 쓰여져 있다. + 주사위를 굴렸을 때, 이동한 칸에 쓰여 있는 수가 0이면, + 주사위의 바닥면에 쓰여 있는 수가 칸에 복사된다. + 0이 아닌 경우에는 칸에 쓰여 있는 수가 주사위의 바닥면으로 복사되며, + 칸에 쓰여 있는 수는 0이 된다. + + 주사위를 놓은 곳의 좌표와 이동시키는 명령이 주어졌을 때, + 주사위가 이동했을 때마다 상단에 쓰여 있는 값을 구하는 프로그램을 작성하시오. + + 주사위는 지도의 바깥으로 이동시킬 수 없다. + 만약 바깥으로 이동시키려고 하는 경우에는 해당 명령을 무시해야 하며, + 출력도 하면 안 된다. + + + [입력] + + 첫째 줄에 지도의 세로 크기 N, 가로 크기 M (1 ≤ N, M ≤ 20), + 주사위를 놓은 곳의 좌표 x, y(0 ≤ x ≤ N-1, 0 ≤ y ≤ M-1), + 그리고 명령의 개수 K (1 ≤ K ≤ 1,000)가 주어진다. + + 둘째 줄부터 N개의 줄에 지도에 쓰여 있는 수가 북쪽부터 남쪽으로, + 각 줄은 서쪽부터 동쪽 순서대로 주어진다. + 주사위를 놓은 칸에 쓰여 있는 수는 항상 0이다. + 지도의 각 칸에 쓰여 있는 수는 10 미만의 자연수 또는 0이다. + + 마지막 줄에는 이동하는 명령이 순서대로 주어진다. + 동쪽은 1, 서쪽은 2, 북쪽은 3, 남쪽은 4로 주어진다. + + + [출력] + + 이동할 때마다 주사위의 윗 면에 쓰여 있는 수를 출력한다. + 만약 바깥으로 이동시키려고 하는 경우에는 해당 명령을 무시해야 하며, + 출력도 하면 안 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("주사위 굴리기") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("simulation,implementation") + .build(); + + problemRepository.save(problem); + } + + // 8. 목표지점 거리 + private void createTargetDistanceProblem() { + if (problemRepository.existsByTitle("목표지점 거리")) { + return; + } + + String description = """ + [문제] + + 지도가 주어지면 모든 지점에 대해서 목표지점까지의 거리를 구하여라. + 문제를 쉽게 만들기 위해 오직 가로와 세로로만 움직일 수 있다고 하자. + + [입력] + + 지도의 크기 n과 m이 주어진다. n은 세로의 크기, m은 가로의 크기다.(2 ≤ n ≤ 1000, 2 ≤ m ≤ 1000) + 다음 n개의 줄에 m개의 숫자가 주어진다. 0은 갈 수 없는 땅이고 1은 갈 수 있는 땅, 2는 목표지점이다. 입력에서 2는 단 한개이다. + + [출력] + + 각 지점에서 목표지점까지의 거리를 출력한다. + 원래 갈 수 없는 땅인 위치는 0을 출력하고, 원래 갈 수 있는 땅인 부분 중에서 도달할 수 없는 위치는 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("목표지점 거리") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("bfs,graph") + .build(); + + problemRepository.save(problem); + } + + // 9. DFS와 BFS + private void createDfsBfsProblem() { + if (problemRepository.existsByTitle("DFS와 BFS")) { + return; + } + + String description = """ + [문제] + + 그래프를 DFS로 탐색한 결과와 BFS로 탐색한 결과를 출력하는 프로그램을 작성하시오. + 단, 방문할 수 있는 정점이 여러 개인 경우에는 정점 번호가 작은 것을 먼저 방문하고, + 더 이상 방문할 수 있는 점이 없는 경우 종료한다. + 정점 번호는 1번부터 N번까지이다. + + + [입력] + + 첫째 줄에 정점의 개수 N(1 ≤ N ≤ 1,000), 간선의 개수 M(1 ≤ M ≤ 10,000), 탐색을 시작할 정점의 번호 V가 주어진다. + 다음 M개의 줄에는 간선이 연결하는 두 정점의 번호가 주어진다. + 어떤 두 정점 사이에 여러 개의 간선이 있을 수 있다. 입력으로 주어지는 간선은 양방향이다. + + + [출력] + + 첫째 줄에 DFS를 수행한 결과를, 그 다음 줄에는 BFS를 수행한 결과를 출력한다. + V부터 방문된 점을 순서대로 출력하면 된다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("DFS와 BFS") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("graph,dfs,bfs") + .build(); + + problemRepository.save(problem); + } + + // 10. 여행 가자 (New) + private void createTripPlanningProblem() { + if (problemRepository.existsByTitle("여행 가자")) { + return; + } + + String description = """ + [문제] + + 동혁이는 친구들과 함께 여행을 가려고 한다. 한국에는 도시가 N개 있고 임의의 두 도시 사이에 길이 있을 수도, 없을 수도 있다. + 동혁이의 여행 일정이 주어졌을 때, 이 여행 경로가 가능한 것인지 알아보자. 물론 중간에 다른 도시를 경유해서 여행을 할 수도 있다. + 예를 들어 도시가 5개 있고, A-B, B-C, A-D, B-D, E-A의 길이 있고, 동혁이의 여행 계획이 E C B C D 라면 E-A-B-C-B-C-B-D라는 여행경로를 통해 목적을 달성할 수 있다. + + 도시들의 개수와 도시들 간의 연결 여부가 주어져 있고, 동혁이의 여행 계획에 속한 도시들이 순서대로 주어졌을 때 가능한지 여부를 판별하는 프로그램을 작성하시오. + 같은 도시를 여러 번 방문하는 것도 가능하다. + + + [입력] + + 첫 줄에 도시의 수 N이 주어진다. N은 200이하이다. 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. M은 1000이하이다. + 다음 N개의 줄에는 N개의 정수가 주어진다. i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. + 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. A와 B가 연결되었으면 B와 A도 연결되어 있다. + 마지막 줄에는 여행 계획이 주어진다. 도시의 번호는 1부터 N까지 차례대로 매겨져 있다. + + + [출력] + + 첫 줄에 가능하면 YES 불가능하면 NO를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("여행 가자") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("graph,union_find,bfs") + .build(); + + problemRepository.save(problem); + } + + // ========================= + // HARD 문제들 + // ========================= + + // 11. 구슬 탈출 + private void createMarbleEscapeProblem() { + if (problemRepository.existsByTitle("구슬 탈출")) { + return; + } + + String description = """ + [문제] + + 스타트링크에서 판매하는 어린이용 장난감 중에서 가장 인기가 많은 제품은 구슬 탈출이다. + 구슬 탈출은 직사각형 보드에 빨간 구슬과 파란 구슬을 하나씩 넣은 다음, + 빨간 구슬을 구멍을 통해 빼내는 게임이다. + + 보드의 세로 크기는 N, 가로 크기는 M이고, 편의상 1×1 크기의 칸으로 나누어져 있다. + 가장 바깥 행과 열은 모두 막혀져 있고, 보드에는 구멍이 하나 있다. + 빨간 구슬과 파란 구슬의 크기는 보드에서 1×1 크기의 칸을 가득 채우는 사이즈이고, + 각각 하나씩 들어가 있다. + + 이때, 구슬을 손으로 건드릴 수는 없고, 중력을 이용해서 이리 저리 굴려야 한다. + 왼쪽으로 기울이기, 오른쪽으로 기울이기, 위쪽으로 기울이기, + 아래쪽으로 기울이기와 같은 네 가지 동작이 가능하다. + + 각각의 동작에서 공은 동시에 움직인다. + 빨간 구슬이 구멍에 빠지면 성공이지만, 파란 구슬이 구멍에 빠지면 실패이다. + 빨간 구슬과 파란 구슬이 동시에 구멍에 빠져도 실패이다. + 빨간 구슬과 파란 구슬은 동시에 같은 칸에 있을 수 없다. + 또, 빨간 구슬과 파란 구슬의 크기는 한 칸을 모두 차지한다. + 기울이는 동작을 그만하는 것은 더 이상 구슬이 움직이지 않을 때까지이다. + + 보드의 상태가 주어졌을 때, + 최소 몇 번 만에 빨간 구슬을 구멍을 통해 빼낼 수 있는지 구하는 프로그램을 작성하시오. + + + [입력] + + 첫 번째 줄에는 보드의 세로, 가로 크기를 의미하는 두 정수 N, M (3 ≤ N, M ≤ 10)이 주어진다. + 다음 N개의 줄에 보드의 모양을 나타내는 길이 M의 문자열이 주어진다. + 이 문자열은 '.', '#', 'O', 'R', 'B' 로 이루어져 있다. + '.'은 빈 칸을 의미하고, '#'은 공이 이동할 수 없는 장애물 또는 벽을 의미하며, + 'O'는 구멍의 위치를 의미한다. + 'R'은 빨간 구슬의 위치, 'B'는 파란 구슬의 위치이다. + + 입력되는 모든 보드의 가장자리에는 모두 '#'이 있다. + 구멍의 개수는 한 개이며, 빨간 구슬과 파란 구슬은 항상 1개가 주어진다. + + + [출력] + + 최소 몇 번 만에 빨간 구슬을 구멍을 통해 빼낼 수 있는지 출력한다. + 만약, 10번 이하로 움직여서 빨간 구슬을 구멍을 통해 빼낼 수 없으면 -1을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("구슬 탈출") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,bfs,implementation") + .build(); + + problemRepository.save(problem); + } + + // 12. 마법사 상어와 복제 + private void createSharkCopyMagicProblem() { + if (problemRepository.existsByTitle("마법사 상어와 복제")) { + return; + } + + String description = """ + [문제] + + 마법사 상어는 파이어볼, 토네이도, 파이어스톰, 물복사버그, 비바라기, 블리자드 마법을 할 수 있다. + 오늘은 기존에 배운 물복사버그 마법의 상위 마법인 복제를 배웠고, + 4 × 4 크기의 격자에서 연습하려고 한다. + (r, c)는 격자의 r행 c열을 의미한다. + 격자의 가장 왼쪽 윗 칸은 (1, 1)이고, 가장 오른쪽 아랫 칸은 (4, 4)이다. + + 격자에는 물고기 M마리가 있다. + 각 물고기는 격자의 칸 하나에 들어가 있으며, 이동 방향을 가지고 있다. + 이동 방향은 8가지 방향(상하좌우, 대각선) 중 하나이다. + 마법사 상어도 연습을 위해 격자에 들어가있다. + 상어도 격자의 한 칸에 들어가있다. + 둘 이상의 물고기가 같은 칸에 있을 수도 있으며, + 마법사 상어와 물고기가 같은 칸에 있을 수도 있다. + + 상어의 마법 연습 한 번은 다음과 같은 작업이 순차적으로 이루어진다. + + 1. 상어가 모든 물고기에게 복제 마법을 시전한다. + 복제 마법은 시간이 조금 걸리기 때문에, 아래 5번에서 물고기가 복제되어 나타난다. + + 2. 모든 물고기가 한 칸 이동한다. + 상어가 있는 칸, 물고기의 냄새가 있는 칸, 격자의 범위를 벗어나는 칸으로는 이동할 수 없다. + 각 물고기는 자신이 가지고 있는 이동 방향이 이동할 수 있는 칸을 향할 때까지 + 방향을 45도 반시계 회전시킨다. + 이동할 수 있는 칸이 없으면 이동하지 않는다. + + 3. 상어가 연속해서 3칸 이동한다. + 상어는 상하좌우로 인접한 칸으로 이동할 수 있다. + 이동 중 격자를 벗어나면 그 방법은 불가능하다. + 이동 중 물고기가 있는 칸에 도착하면, 그 칸의 모든 물고기는 제거되고 냄새를 남긴다. + 가능한 이동 방법 중 제거되는 물고기가 가장 많은 방법을 선택하며, + 동일하다면 사전순으로 가장 앞서는 방법을 선택한다. + + 4. 두 번 전 연습에서 생긴 물고기의 냄새가 격자에서 사라진다. + + 5. 1에서 사용된 복제 마법이 완료되어 복제된 물고기가 생성된다. + + + [입력] + + 첫째 줄에 물고기의 수 M, 연습 횟수 S가 주어진다. + 다음 M개의 줄에는 물고기의 정보 (fx, fy, d)가 주어지며, + d는 1~8 방향을 의미한다. (←, ↖, ↑, ↗, →, ↘, ↓, ↙) + + 마지막 줄에는 상어의 위치 (sx, sy)가 주어진다. + + 격자 위에 있는 물고기의 수가 항상 1,000,000 이하인 입력만 주어진다. + + + [출력] + + S번의 연습을 마친 후 격자에 있는 물고기의 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("마법사 상어와 복제") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,backtracking,implementation") + .build(); + + problemRepository.save(problem); + } + + // 13. 비슷한 단어 + private void createSimilarWordsProblem() { + if (problemRepository.existsByTitle("비슷한 단어")) { + return; + } + + String description = """ + [문제] + + N개의 영단어들이 주어졌을 때, 가장 비슷한 두 단어를 구해내는 프로그램을 작성하시오. + + 두 단어의 비슷한 정도는 두 단어의 접두사의 길이로 측정한다. + 접두사란 두 단어의 앞부분에서 공통적으로 나타나는 부분문자열을 말한다. + 즉, 두 단어의 앞에서부터 M개의 글자들이 같으면서 M이 최대인 경우를 구하는 것이다. + "AHEHHEH", "AHAHEH"의 접두사는 "AH"가 되고, "AB", "CD"의 접두사는 ""(길이가 0)이 된다. + + 접두사의 길이가 최대인 경우가 여러 개일 때에는 입력되는 순서대로 제일 앞쪽에 있는 단어를 답으로 한다. + 즉, 답으로 S라는 문자열과 T라는 문자열을 출력한다고 했을 때, + 우선 S가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력하고, + 그런 경우도 여러 개 있을 때에는 그 중에서 T가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력한다. + + + [입력] + + 첫째 줄에 N(2 ≤ N ≤ 20,000)이 주어진다. + 다음 N개의 줄에 알파벳 소문자로만 이루어진 길이 100자 이하의 서로 다른 영단어가 주어진다. + + + [출력] + + 첫째 줄에 S를, 둘째 줄에 T를 출력한다. + 단, 이 두 단어는 서로 달라야 한다. 즉, 가장 비슷한 두 단어를 구할 때 같은 단어는 제외하는 것이다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("비슷한 단어") + .difficulty(Difficulty.HARD) + .description(description) + .tags("string,sorting") + .build(); + + problemRepository.save(problem); + } + + // 14. 보석 도둑 + private void createJewelThiefProblem() { + if (problemRepository.existsByTitle("보석 도둑")) { + return; + } + + String description = """ + [문제] + + 세계적인 도둑 상덕이는 보석점을 털기로 결심했다. + 상덕이가 털 보석점에는 보석이 총 N개 있다. 각 보석은 무게 Mi와 가격 Vi를 가지고 있다. + 상덕이는 가방을 K개 가지고 있고, 각 가방에 담을 수 있는 최대 무게는 Ci이다. + 가방에는 최대 한 개의 보석만 넣을 수 있다. + 상덕이가 훔칠 수 있는 보석의 최대 가격을 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N과 K가 주어진다. (1 ≤ N, K ≤ 300,000) + 다음 N개 줄에는 각 보석의 정보 Mi와 Vi가 주어진다. (0 ≤ Mi, Vi ≤ 1,000,000) + 다음 K개 줄에는 가방에 담을 수 있는 최대 무게 Ci가 주어진다. (1 ≤ Ci ≤ 100,000,000) + 모든 숫자는 양의 정수이다. + + + [출력] + + 첫째 줄에 상덕이가 훔칠 수 있는 보석 가격의 합의 최댓값을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("보석 도둑") + .difficulty(Difficulty.HARD) + .description(description) + .tags("greedy,sorting,priority_queue") + .build(); + + problemRepository.save(problem); + } + + // 15. 화성 탐사 (New) + private void createMarsExplorationProblem() { + if (problemRepository.existsByTitle("화성 탐사")) { + return; + } + + String description = """ + [문제] + + NASA에서는 화성 탐사를 위해 화성에 무선 조종 로봇을 보냈다. 실제 화성의 모습은 굉장히 복잡하지만, + 로봇의 메모리가 얼마 안 되기 때문에 지형을 N×M 배열로 단순화 하여 생각하기로 한다. + 지형의 고저차의 특성상, 로봇은 움직일 때 배열에서 왼쪽, 오른쪽, 아래쪽으로 이동할 수 있지만, 위쪽으로는 이동할 수 없다. + 또한 한 번 탐사한 지역(배열에서 하나의 칸)은 탐사하지 않기로 한다. + + 각각의 지역은 탐사 가치가 있는데, 로봇을 배열의 왼쪽 위 (1, 1)에서 출발시켜 오른쪽 아래 (N, M)으로 보내려고 한다. + 이때, 위의 조건을 만족하면서, 탐사한 지역들의 가치의 합이 최대가 되도록 하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N, M(1≤N, M≤1,000)이 주어진다. 다음 N개의 줄에는 M개의 수로 배열이 주어진다. + 배열의 각 수는 절댓값이 100을 넘지 않는 정수이다. 이 값은 그 지역의 가치를 나타낸다. + + + [출력] + + 첫째 줄에 최대 가치의 합을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("화성 탐사") + .difficulty(Difficulty.HARD) + .description(description) + .tags("dp") + .build(); + + problemRepository.save(problem); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java new file mode 100644 index 0000000..bec9308 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java @@ -0,0 +1,148 @@ +package com.example.skillboost.codingtest.judge; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiJudge { + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.model}") + private String model; + + private final ObjectMapper objectMapper; + + public SubmissionResultDto grade(CodingProblem problem, String userCode, String language) { + + String prompt = createPrompt(problem, userCode, language); + String apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/" + + model + ":generateContent?key=" + apiKey; + + try { + RestTemplate restTemplate = new RestTemplate(); + + Map body = Map.of( + "contents", List.of( + Map.of("parts", List.of( + Map.of("text", prompt) + )) + ) + ); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.exchange(apiUrl, HttpMethod.POST, entity, String.class); + + return parseResponse(response.getBody(), problem.getTestCases().size()); + + } catch (Exception e) { + log.error("AI 채점 실패", e); + return SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("AI 서버와 연결할 수 없습니다.") + .aiFeedback("일시적인 오류입니다. 잠시 후 다시 시도해주세요.") + .build(); + } + } + + private SubmissionResultDto parseResponse(String jsonResponse, int totalTestCases) { + try { + JsonNode root = objectMapper.readTree(jsonResponse); + + String rawText = null; + JsonNode cand = root.path("candidates").get(0); + + if (cand.has("output_text")) { + rawText = cand.path("output_text").asText(); + } + + if (rawText == null || rawText.isEmpty()) { + JsonNode parts = cand.path("content").path("parts"); + if (parts.isArray() && parts.size() > 0) { + rawText = parts.get(0).path("text").asText(); + } + } + + if (rawText == null || rawText.isEmpty()) { + throw new RuntimeException("AI 응답 파싱 실패"); + } + + rawText = rawText.replace("```json", "") + .replace("```", "") + .trim(); + + JsonNode resultNode = objectMapper.readTree(rawText); + + String status = resultNode.path("status").asText("WA"); + int score = resultNode.path("score").asInt(0); + String feedback = resultNode.path("feedback").asText("피드백 없음"); + + int passedCount = (score == 100) + ? totalTestCases + : (int) Math.round(totalTestCases * (score / 100.0)); + + return SubmissionResultDto.builder() + .status(status) + .score(score) + .passedCount(passedCount) + .totalCount(totalTestCases) + .message(score == 100 ? "정답입니다! 🎉" : "오답입니다.") + .aiFeedback(feedback) + .build(); + + } catch (Exception e) { + log.error("AI 응답 파싱 실패", e); + return SubmissionResultDto.builder() + .status("ERROR") + .score(0) + .message("채점 오류") + .aiFeedback("AI 응답 분석 실패") + .build(); + } + } + + private String createPrompt(CodingProblem problem, String userCode, String language) { + return """ + You are a strict Algorithm Coding Test Judge. + + [PROBLEM TITLE]: %s + [PROBLEM DESCRIPTION]: %s + + [USER CODE - %s]: + %s + + Return ONLY pure JSON (no extra text): + + { + "status": "AC" or "WA", + "score": 0~100, + "feedback": "한국어 피드백" + } + """.formatted( + problem.getTitle(), + problem.getDescription(), + language, + userCode + ); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java b/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java new file mode 100644 index 0000000..3e4bd72 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java @@ -0,0 +1,185 @@ +package com.example.skillboost.codingtest.judge; + +import org.springframework.stereotype.Component; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Component +public class JudgeClient { + + private static final String TEMP_DIR = System.getProperty("java.io.tmpdir"); + private static final int TIMEOUT_SECONDS = 2; // 시간 제한 + + /** + * CodingTestService에서 호출하는 메서드 + * 소스코드, 언어, 입력값을 받아 실행 결과를 반환 + */ + public JudgeResult execute(String sourceCode, String language, String input) { + String uniqueId = UUID.randomUUID().toString(); + File sourceFile = createSourceFile(language, sourceCode, uniqueId); + + if (sourceFile == null) { + return JudgeResult.runtimeError("Internal Error: 파일 생성 실패"); + } + + try { + // 1. 컴파일 (Java, C++ 만) + if (language.equalsIgnoreCase("java") || language.equalsIgnoreCase("cpp")) { + String compileError = compileCode(language, sourceFile); + if (compileError != null) { + return JudgeResult.compileError(compileError); + } + } + + // 2. 실행 + return runCode(language, sourceFile, input); + + } catch (Exception e) { + return JudgeResult.runtimeError(e.getMessage()); + } finally { + cleanup(sourceFile); + } + } + + // --- 내부 헬퍼 메서드 --- + + private File createSourceFile(String language, String code, String uniqueId) { + try { + String fileName; + // 언어별 파일 확장자 및 클래스명 처리 + if (language.equalsIgnoreCase("java")) { + fileName = "Main.java"; // Java는 Main 클래스 강제 + } else if (language.equalsIgnoreCase("cpp")) { + fileName = uniqueId + ".cpp"; + } else { // python + fileName = uniqueId + ".py"; + } + + // 폴더 분리 (동시 실행 충돌 방지) + Path dirPath = Path.of(TEMP_DIR, "judge_" + uniqueId); + Files.createDirectories(dirPath); + + File file = dirPath.resolve(fileName).toFile(); + try (FileWriter writer = new FileWriter(file)) { + writer.write(code); + } + return file; + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private String compileCode(String language, File sourceFile) { + ProcessBuilder pb; + if (language.equalsIgnoreCase("java")) { + // javac -encoding UTF-8 Main.java + pb = new ProcessBuilder("javac", "-encoding", "UTF-8", sourceFile.getAbsolutePath()); + } else { + // g++ -o output source.cpp + String outputPath = sourceFile.getParent() + File.separator + "output"; + // Windows인 경우 .exe 붙임 + if (System.getProperty("os.name").toLowerCase().contains("win")) { + outputPath += ".exe"; + } + pb = new ProcessBuilder("g++", "-o", outputPath, sourceFile.getAbsolutePath()); + } + + pb.directory(sourceFile.getParentFile()); + pb.redirectErrorStream(true); + + try { + Process process = pb.start(); + boolean finished = process.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + process.destroy(); + return "Time Limit Exceeded during Compilation"; + } + if (process.exitValue() != 0) { + return readProcessOutput(process.getInputStream()); + } + return null; // 컴파일 성공 + } catch (Exception e) { + return e.getMessage(); + } + } + + private JudgeResult runCode(String language, File sourceFile, String input) { + ProcessBuilder pb; + long startTime = System.currentTimeMillis(); + + try { + if (language.equalsIgnoreCase("java")) { + pb = new ProcessBuilder("java", "-cp", ".", "Main"); + } else if (language.equalsIgnoreCase("python")) { + pb = new ProcessBuilder("python", sourceFile.getName()); // python3 라면 "python3" + } else { // cpp + String cmd = System.getProperty("os.name").toLowerCase().contains("win") ? "output.exe" : "./output"; + pb = new ProcessBuilder(cmd); + } + + pb.directory(sourceFile.getParentFile()); + Process process = pb.start(); + + // 입력값 주입 + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream()))) { + writer.write(input); + writer.flush(); + } + + // 실행 대기 + boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroy(); + return JudgeResult.builder().statusId(5).message("Time Limit Exceeded").build(); + } + + // 결과 읽기 + String output = readProcessOutput(process.getInputStream()); + String error = readProcessOutput(process.getErrorStream()); + double duration = (System.currentTimeMillis() - startTime) / 1000.0; + + if (process.exitValue() != 0) { + return JudgeResult.runtimeError(error.isEmpty() ? "Runtime Error" : error); + } + + // 로컬 실행 성공 (정답 여부는 Service에서 판단하므로 여기선 성공 상태 리턴) + // JudgeResult.accepted()는 statusId=3을 반환하여 Service가 정답 비교를 진행하게 함 + return JudgeResult.accepted(output, duration); + + } catch (Exception e) { + return JudgeResult.runtimeError(e.getMessage()); + } + } + + private String readProcessOutput(InputStream inputStream) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + } + return sb.toString().trim(); + } + + private void cleanup(File sourceFile) { + try { + if (sourceFile == null) return; + File dir = sourceFile.getParentFile(); + if (dir != null && dir.exists()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) f.delete(); + } + dir.delete(); + } + } catch (Exception e) { + // ignore + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java b/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java new file mode 100644 index 0000000..c09f6cf --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java @@ -0,0 +1,55 @@ +package com.example.skillboost.codingtest.judge; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JudgeResult { + // Judge0 표준 상태 코드 (3: Accepted, 4: Wrong Answer, 5: Time Limit, 6: Compilation Error, 11: Runtime Error) + private int statusId; + + private String stdout; // 표준 출력 결과 + private String stderr; // 에러 메시지 + private String message; // 설명 + private double time; // 실행 시간 + private long memory; // 메모리 사용량 + + public static JudgeResult accepted(String output, double time) { + return JudgeResult.builder() + .statusId(3) // Accepted + .stdout(output) + .time(time) + .message("Accepted") + .build(); + } + + public static JudgeResult wrongAnswer(String output, double time) { + return JudgeResult.builder() + .statusId(4) // Wrong Answer + .stdout(output) + .time(time) + .message("Wrong Answer") + .build(); + } + + public static JudgeResult compileError(String errorMessage) { + return JudgeResult.builder() + .statusId(6) // Compilation Error + .stderr(errorMessage) + .message("Compilation Error") + .build(); + } + + public static JudgeResult runtimeError(String errorMessage) { + return JudgeResult.builder() + .statusId(11) // Runtime Error + .stderr(errorMessage) + .message("Runtime Error") + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java new file mode 100644 index 0000000..fe01138 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java @@ -0,0 +1,16 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.Difficulty; // ★ 이 import가 꼭 있어야 합니다 +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CodingProblemRepository extends JpaRepository { + + // 제목으로 문제 찾기 (중복 데이터 생성 방지용) + boolean existsByTitle(String title); + + // ★ [핵심] 이 줄이 없어서 에러가 난 것입니다. 추가해주세요! + List findAllByDifficulty(Difficulty difficulty); +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java new file mode 100644 index 0000000..03d5c06 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java @@ -0,0 +1,7 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingSubmission; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CodingSubmissionRepository extends JpaRepository { +} diff --git a/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java b/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java new file mode 100644 index 0000000..8ce37b1 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java @@ -0,0 +1,15 @@ +package com.example.skillboost.codingtest.repository; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.domain.CodingTestCase; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CodingTestCaseRepository extends JpaRepository { + + List findByProblem(CodingProblem problem); + + // 또는 problemId로 바로 찾고 싶으면 + List findByProblem_Id(Long problemId); +} diff --git a/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java new file mode 100644 index 0000000..d5390d8 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java @@ -0,0 +1,8 @@ +package com.example.skillboost.codingtest.service; + +import org.springframework.stereotype.Service; + +@Service +public class CodingTestService { + // TODO: 현재 사용하지 않음. 나중에 제출 기록 저장 기능 추가할 때 구현. +} diff --git a/src/main/java/com/example/skillboost/codingtest/service/GradingService.java b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java new file mode 100644 index 0000000..149ddfc --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java @@ -0,0 +1,29 @@ +package com.example.skillboost.codingtest.service; + +import com.example.skillboost.codingtest.domain.CodingProblem; +import com.example.skillboost.codingtest.dto.SubmissionRequestDto; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import com.example.skillboost.codingtest.judge.GeminiJudge; +import com.example.skillboost.codingtest.repository.CodingProblemRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GradingService { + + private final CodingProblemRepository problemRepository; + private final GeminiJudge judge; + + public SubmissionResultDto grade(SubmissionRequestDto request) { + + CodingProblem problem = problemRepository.findById(request.getProblemId()) + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); + + // DB에 제출 저장 같은 건 나중에 하고, + // 일단 AI 채점만 연결 + return judge.grade(problem, request.getCode(), request.getLanguage()); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..dada28b --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,47 @@ +# 서버 포트 설정 +server: + port: 8080 + +# Spring Boot 애플리케이션 기본 설정 +spring: + application: + name: skill-boost + + # JPA 설정 (테이블 자동 생성을 위해 ddl-auto: update 추가) + jpa: + hibernate: + ddl-auto: update + # (선택사항) 실행되는 SQL을 로그로 보려면 주석 해제 + # show-sql: true + + # GitHub OAuth2 로그인 설정 + security: + oauth2: + client: + registration: + github: + client-id: Ov23liXAPa0etQe0EisI + client-secret: ${GITHUB_CLIENT_SECRET} # .env 파일에서 읽어옴 + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +# SpringDoc (Swagger) 설정 +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + path: /swagger-ui.html + +# JWT 토큰 설정 +jwt: + secret-key: ${JWT_SECRET_KEY} # .env 파일에서 읽어옴 + expiration-ms: 86400000 # 토큰 만료 시간 (24시간) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..f7044b2 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,34 @@ +spring: + application: + name: skill-boost + + datasource: + url: ${MYSQL_URL} + username: ${MYSQL_USER} + password: ${MYSQL_PASSWORD} + + jpa: + hibernate: + ddl-auto: none + + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: ${JWT_SECRET_KEY} + expiration-ms: 86400000 diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..66363b4 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,32 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MYSQL + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + + security: + oauth2: + client: + registration: + github: + client-id: test + client-secret: test + scope: + - read:user + - user:email + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id + +jwt: + secret-key: test-secret + expiration-ms: 100000 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 666da9c..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=skill-boost diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..35e1022 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + config: + import: optional:classpath:application-secret.yml + application: + name: skill-boost + + datasource: + url: jdbc:mysql://localhost:3306/mydatabase?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: myuser + password: secret + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show-sql: true + +server: + port: 8080 + +gemini: + api: + key: # + model: gemini-2.5-flash