From c97e5c8dc2635055fc6133356def3209ebbeb683 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 16:53:43 +0900 Subject: [PATCH 01/21] =?UTF-8?q?build:=20Gradle=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80(Security,=20OAuth2,=20JWT,=20Swa?= =?UTF-8?q?gger)(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/build.gradle b/build.gradle index 710349c..9394c53 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,14 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + } tasks.named('test') { From 8304e91bb473cfaee9f257f05659bc13aa301043 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 16:55:44 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/skillboost/domain/User.java | 25 +++++++++++++++++++ .../skillboost/repository/UserRepository.java | 9 +++++++ 2 files changed, 34 insertions(+) create mode 100644 src/main/java/com/example/skillboost/domain/User.java create mode 100644 src/main/java/com/example/skillboost/repository/UserRepository.java diff --git a/src/main/java/com/example/skillboost/domain/User.java b/src/main/java/com/example/skillboost/domain/User.java new file mode 100644 index 0000000..fd4aac4 --- /dev/null +++ b/src/main/java/com/example/skillboost/domain/User.java @@ -0,0 +1,25 @@ +package com.example.skillboost.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String username; + private String githubId; + private String provider; // github, local +} diff --git a/src/main/java/com/example/skillboost/repository/UserRepository.java b/src/main/java/com/example/skillboost/repository/UserRepository.java new file mode 100644 index 0000000..7bb196b --- /dev/null +++ b/src/main/java/com/example/skillboost/repository/UserRepository.java @@ -0,0 +1,9 @@ +package com.example.skillboost.repository; + +import com.example.skillboost.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file From 0e0b11291806be49bc9de983f4d0ba7d61fb91fe Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 16:57:00 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20JWT=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EA=B5=AC=ED=98=84(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/skillboost/auth/JwtFilter.java | 59 +++++++++++ .../example/skillboost/auth/JwtProvider.java | 98 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/main/java/com/example/skillboost/auth/JwtFilter.java create mode 100644 src/main/java/com/example/skillboost/auth/JwtProvider.java diff --git a/src/main/java/com/example/skillboost/auth/JwtFilter.java b/src/main/java/com/example/skillboost/auth/JwtFilter.java new file mode 100644 index 0000000..0f81fbb --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/JwtFilter.java @@ -0,0 +1,59 @@ +package com.example.skillboost.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + public static final String AUTHORIZATION_HEADER = "Authorization"; + public static final String BEARER_PREFIX = "Bearer "; + + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws IOException, ServletException { + + // Request Header에서 JWT 토큰 추출 + String jwt = resolveToken(request); + + // JWT 토큰 유효성 검증 + if (StringUtils.hasText(jwt) && jwtProvider.validateToken(jwt)) { + // 유효한 토큰이면 Authentication 객체를 생성하여 SecurityContext에 저장 + Authentication authentication = jwtProvider.getAuthentication(jwt); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("JWT 인증 성공: {}", authentication.getName()); + } else if (StringUtils.hasText(jwt)) { + log.warn("유효하지 않은 JWT 토큰"); + } + + // 다음 필터로 진행 + filterChain.doFilter(request, response); + } + + /** + * Request Header에서 JWT 토큰 추출 + */ + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()).trim(); + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java new file mode 100644 index 0000000..bfd4237 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -0,0 +1,98 @@ +package com.example.skillboost.auth; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; + +@Slf4j +@Component +public class JwtProvider { + + private Key key; + + @Value("${jwt.secret-key}") + private String secretKeyBase64; + + @Value("${jwt.expiration-ms}") + private long expirationMs; + + @PostConstruct // ✅ 이거 꼭 있어야 합니다! + protected void init() { + // Base64로 인코딩된 secret key를 디코딩하여 Key 객체 생성 + byte[] keyBytes = Base64.getDecoder().decode(this.secretKeyBase64); + this.key = Keys.hmacShaKeyFor(keyBytes); + log.info("JWT Provider 초기화 완료"); + } + + /** + * JWT 토큰 생성 + */ + public String createToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + this.expirationMs); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * JWT 토큰 유효성 검증 + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + log.error("JWT 토큰이 만료되었습니다: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.error("지원되지 않는 JWT 토큰입니다: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.error("잘못된 형식의 JWT 토큰입니다: {}", e.getMessage()); + } catch (SecurityException e) { + log.error("JWT 서명이 올바르지 않습니다: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.error("JWT 토큰이 비어있습니다: {}", e.getMessage()); + } + return false; + } + + /** + * JWT 토큰에서 Authentication 객체 생성 + */ + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + + String email = claims.getSubject(); + + User principal = new User( + email, + "", + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")) + ); + + return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + } +} \ No newline at end of file From 04c5c872fa2e74450bf10d1e3c3336a4b0c4f1cd Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 16:58:27 +0900 Subject: [PATCH 04/21] =?UTF-8?q?config:=20Security=20=EB=B0=8F=20Swagger?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/config/SecurityConfig.java | 60 +++++++++++++++++++ .../skillboost/auth/config/SwaggerConfig.java | 37 ++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/main/java/com/example/skillboost/auth/config/SecurityConfig.java create mode 100644 src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java new file mode 100644 index 0000000..aa6126a --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -0,0 +1,60 @@ +package com.example.skillboost.auth.config; + +import com.example.skillboost.auth.JwtFilter; +import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.auth.handler.OAuth2SuccessHandler; +import com.example.skillboost.auth.service.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtProvider jwtProvider; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ✅ 파라미터 제거! + http + // 요청 권한 설정 + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/**", + "/oauth2/**", + "/login/oauth2/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/favicon.ico" + ).permitAll() + .anyRequest().authenticated() + ) + // CSRF 비활성화 + .csrf(AbstractHttpConfigurer::disable) + // JWT 필터 적용 + .addFilterBefore(new JwtFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class) + // 기본 폼 로그인 및 HTTP Basic 인증 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + // OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .successHandler(oAuth2SuccessHandler) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + ); + + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java b/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java new file mode 100644 index 0000000..89870db --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/config/SwaggerConfig.java @@ -0,0 +1,37 @@ +package com.example.skillboost.auth.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + Server localServer = new Server() + .url("http://localhost:8080") + .description("Local Server"); + + + return new OpenAPI() + .servers(List.of(localServer)) + .components(new Components() + .addSecuritySchemes("bearer-token", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .addSecurityItem(new SecurityRequirement().addList("bearer-token")) + .info(new Info() + .title("My Application API") + .description("API Documentation") + .version("1.0.0")); + } +} \ No newline at end of file From daaa550ca8d47ec8239d5039c37dd3f2f870a495 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 17:00:02 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20AuthController=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EA=B9=83=ED=97=88=EB=B8=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20URL=20API=20=EC=B6=94=EA=B0=80(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/java/com/example/skillboost/auth/controller/AuthController.java diff --git a/src/main/java/com/example/skillboost/auth/controller/AuthController.java b/src/main/java/com/example/skillboost/auth/controller/AuthController.java new file mode 100644 index 0000000..82dc33f --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/controller/AuthController.java @@ -0,0 +1,23 @@ +package com.example.skillboost.auth.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; + +@Tag(name = "깃허브 인증 (Authentication)", description = "소셜 로그인 API") +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + @Operation(summary = "GitHub 로그인 URL 반환", + description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.") + @GetMapping("/github-login-url") + public Map getGithubLoginUrl() { + String loginUrl = "/oauth2/authorization/github"; + + return Map.of("url", loginUrl); + } +} \ No newline at end of file From 8ef3a91de393e8a3edf240bd9adfc5eb2696aa8f Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 17:07:16 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20OAuth2=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=84=B1=EA=B3=B5=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/OAuth2SuccessHandler.java | 75 +++++++++++++++++++ .../auth/service/CustomOAuth2UserService.java | 75 +++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java create mode 100644 src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java new file mode 100644 index 0000000..b484cc8 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,75 @@ +package com.example.skillboost.auth.handler; + +import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.domain.User; +import com.example.skillboost.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + log.info("OAuth2 인증 성공!"); + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + String email = (String) oAuth2User.getAttributes().get("email"); + + // GitHub에서 이메일을 비공개로 설정한 경우 처리 + if (email == null || email.isEmpty()) { + String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); + email = githubId + "@github.temp"; + log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); + } + + // Lambda에서 사용하기 위한 final 변수 + final String finalEmail = email; + + // 사용자 조회 + User user = userRepository.findByEmail(finalEmail) + .orElseThrow(() -> { + log.error("사용자를 찾을 수 없습니다: {}", finalEmail); + return new RuntimeException("User not found: " + finalEmail); + }); + + // JWT 토큰 생성 + String token = jwtProvider.createToken(user.getEmail()); + log.info("JWT 토큰 생성 완료: {}", user.getEmail()); + + // JSON 응답 생성 + Map responseData = new HashMap<>(); + responseData.put("success", true); + responseData.put("token", token); + responseData.put("email", user.getEmail()); + responseData.put("username", user.getUsername()); + + // 클라이언트에 JWT 응답 + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write(objectMapper.writeValueAsString(responseData)); + + // 프론트엔드로 리다이렉트하려면 아래 주석 해제 + // response.sendRedirect("http://localhost:3000/oauth2/redirect?token=" + token); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java b/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..d4cbf0f --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/service/CustomOAuth2UserService.java @@ -0,0 +1,75 @@ +package com.example.skillboost.auth.service; + +import com.example.skillboost.domain.User; +import com.example.skillboost.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + // GitHub에서 사용자 정보 가져오기 + OAuth2User oAuth2User = super.loadUser(request); + Map attributes = oAuth2User.getAttributes(); + + log.info("GitHub OAuth2 사용자 정보: {}", attributes); + + // GitHub 사용자 정보 추출 + String email = (String) attributes.get("email"); + String githubId = String.valueOf(attributes.get("id")); + String username = (String) attributes.get("login"); + + // 이메일이 비공개인 경우 임시 이메일 생성 + if (email == null || email.isEmpty()) { + email = githubId + "@github.temp"; + log.warn("이메일 비공개 사용자 - 임시 이메일 생성: {}", email); + } + + // 사용자 저장 또는 업데이트 + final String finalEmail = email; + User user = userRepository.findByEmail(email) + .map(existing -> { + log.info("기존 사용자 업데이트: {}", finalEmail); + existing.setGithubId(githubId); + existing.setUsername(username); + return existing; + }) + .orElseGet(() -> { + log.info("새로운 사용자 생성: {}", finalEmail); + return User.builder() + .email(finalEmail) + .username(username) + .githubId(githubId) + .provider("github") + .build(); + }); + + userRepository.save(user); + log.info("사용자 정보 저장 완료: {} (GitHub ID: {})", user.getEmail(), user.getGithubId()); + + // OAuth2User 객체 반환 + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, + "id" + ); + } +} \ No newline at end of file From 5222c3004f93527be6caa4409e515d153caedbd5 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 17:17:52 +0900 Subject: [PATCH 07/21] =?UTF-8?q?chore:=20=EC=9D=98=EB=AF=B8=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=ED=95=B4=EC=A0=9C(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/skillboost/auth/JwtProvider.java | 2 +- .../java/com/example/skillboost/auth/config/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index bfd4237..9833ae9 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -28,7 +28,7 @@ public class JwtProvider { @Value("${jwt.expiration-ms}") private long expirationMs; - @PostConstruct // ✅ 이거 꼭 있어야 합니다! + @PostConstruct protected void init() { // Base64로 인코딩된 secret key를 디코딩하여 Key 객체 생성 byte[] keyBytes = Base64.getDecoder().decode(this.secretKeyBase64); diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index aa6126a..f5eff35 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -23,7 +23,7 @@ public class SecurityConfig { private final JwtProvider jwtProvider; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ✅ 파라미터 제거! + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 요청 권한 설정 .authorizeHttpRequests(auth -> auth From 3064898f67fe99912b18ca4e322a4fbd1264433d Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Mon, 24 Nov 2025 17:17:52 +0900 Subject: [PATCH 08/21] =?UTF-8?q?chore:=20=EC=9D=98=EB=AF=B8=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/example/skillboost/auth/JwtProvider.java | 2 +- .../java/com/example/skillboost/auth/config/SecurityConfig.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index bfd4237..9833ae9 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -28,7 +28,7 @@ public class JwtProvider { @Value("${jwt.expiration-ms}") private long expirationMs; - @PostConstruct // ✅ 이거 꼭 있어야 합니다! + @PostConstruct protected void init() { // Base64로 인코딩된 secret key를 디코딩하여 Key 객체 생성 byte[] keyBytes = Base64.getDecoder().decode(this.secretKeyBase64); diff --git a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java index aa6126a..f5eff35 100644 --- a/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java +++ b/src/main/java/com/example/skillboost/auth/config/SecurityConfig.java @@ -23,7 +23,7 @@ public class SecurityConfig { private final JwtProvider jwtProvider; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ✅ 파라미터 제거! + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 요청 권한 설정 .authorizeHttpRequests(auth -> auth From 3ed823f498240ff5ca68431b2aace40c724548f9 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Tue, 25 Nov 2025 19:22:57 +0900 Subject: [PATCH 09/21] =?UTF-8?q?refactor:=20JwtProvider=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EA=B0=95=ED=99=94(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/skillboost/auth/JwtProvider.java | 20 ++++++++++++--- src/main/resources/application-local.yml | 25 +++++++------------ src/main/resources/application.yml | 4 ++- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index 9833ae9..274ae7a 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -30,10 +30,22 @@ public class JwtProvider { @PostConstruct protected void init() { - // Base64로 인코딩된 secret key를 디코딩하여 Key 객체 생성 - byte[] keyBytes = Base64.getDecoder().decode(this.secretKeyBase64); - this.key = Keys.hmacShaKeyFor(keyBytes); - log.info("JWT Provider 초기화 완료"); + log.info("========== JWT 설정 값 확인 =========="); + log.info("입력된 Secret Key: [{}]", this.secretKeyBase64); + + if (this.secretKeyBase64 == null || this.secretKeyBase64.startsWith("${")) { + throw new RuntimeException("환경변수 [JWT_SECRET_KEY]가 설정되지 않았습니다! IntelliJ 설정을 확인해주세요."); + } + String safeKey = this.secretKeyBase64.replaceAll("\\s+", ""); + + try { + byte[] keyBytes = Base64.getDecoder().decode(safeKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + log.info("JWT Provider 정상 초기화 완료"); + } catch (IllegalArgumentException e) { + log.error("Base64 디코딩 실패! 키 값을 확인해주세요. (현재 값: {})", safeKey); + throw e; + } } /** diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index dada28b..ac03546 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,31 +1,26 @@ -# 서버 포트 설정 server: port: 8080 -# Spring Boot 애플리케이션 기본 설정 spring: application: name: skill-boost - - # JPA 설정 (테이블 자동 생성을 위해 ddl-auto: update 추가) + jpa: hibernate: ddl-auto: update - # (선택사항) 실행되는 SQL을 로그로 보려면 주석 해제 - # show-sql: true + show-sql: true - # GitHub OAuth2 로그인 설정 security: oauth2: client: registration: github: client-id: Ov23liXAPa0etQe0EisI - client-secret: ${GITHUB_CLIENT_SECRET} # .env 파일에서 읽어옴 + client-secret: a5dc74aff160176ad62591fbe3d2c0a839eb1ef6 scope: - read:user - user:email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: http://localhost:8080/login/oauth2/code/github provider: github: authorization-uri: https://github.com/login/oauth/authorize @@ -33,15 +28,13 @@ spring: user-info-uri: https://api.github.com/user user-name-attribute: id -# SpringDoc (Swagger) 설정 +jwt: + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= + expiration-ms: 86400000 + 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시간) + path: /swagger-ui.html \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c22d980..82026c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,5 @@ spring: application: - name: skill-boost \ No newline at end of file + name: skill-boost + profiles: + active: local \ No newline at end of file From 3008bbf765000b5479a97e083b86a8725d328fb5 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Tue, 25 Nov 2025 19:22:57 +0900 Subject: [PATCH 10/21] =?UTF-8?q?refactor:=20JwtProvider=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EA=B0=95=ED=99=94(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/skillboost/auth/JwtProvider.java | 20 ++++++++++++--- src/main/resources/application-local.yml | 25 +++++++------------ src/main/resources/application.yml | 4 ++- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index 9833ae9..274ae7a 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -30,10 +30,22 @@ public class JwtProvider { @PostConstruct protected void init() { - // Base64로 인코딩된 secret key를 디코딩하여 Key 객체 생성 - byte[] keyBytes = Base64.getDecoder().decode(this.secretKeyBase64); - this.key = Keys.hmacShaKeyFor(keyBytes); - log.info("JWT Provider 초기화 완료"); + log.info("========== JWT 설정 값 확인 =========="); + log.info("입력된 Secret Key: [{}]", this.secretKeyBase64); + + if (this.secretKeyBase64 == null || this.secretKeyBase64.startsWith("${")) { + throw new RuntimeException("환경변수 [JWT_SECRET_KEY]가 설정되지 않았습니다! IntelliJ 설정을 확인해주세요."); + } + String safeKey = this.secretKeyBase64.replaceAll("\\s+", ""); + + try { + byte[] keyBytes = Base64.getDecoder().decode(safeKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + log.info("JWT Provider 정상 초기화 완료"); + } catch (IllegalArgumentException e) { + log.error("Base64 디코딩 실패! 키 값을 확인해주세요. (현재 값: {})", safeKey); + throw e; + } } /** diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index dada28b..ac03546 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,31 +1,26 @@ -# 서버 포트 설정 server: port: 8080 -# Spring Boot 애플리케이션 기본 설정 spring: application: name: skill-boost - - # JPA 설정 (테이블 자동 생성을 위해 ddl-auto: update 추가) + jpa: hibernate: ddl-auto: update - # (선택사항) 실행되는 SQL을 로그로 보려면 주석 해제 - # show-sql: true + show-sql: true - # GitHub OAuth2 로그인 설정 security: oauth2: client: registration: github: client-id: Ov23liXAPa0etQe0EisI - client-secret: ${GITHUB_CLIENT_SECRET} # .env 파일에서 읽어옴 + client-secret: a5dc74aff160176ad62591fbe3d2c0a839eb1ef6 scope: - read:user - user:email - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: http://localhost:8080/login/oauth2/code/github provider: github: authorization-uri: https://github.com/login/oauth/authorize @@ -33,15 +28,13 @@ spring: user-info-uri: https://api.github.com/user user-name-attribute: id -# SpringDoc (Swagger) 설정 +jwt: + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= + expiration-ms: 86400000 + 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시간) + path: /swagger-ui.html \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c22d980..82026c5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,5 @@ spring: application: - name: skill-boost \ No newline at end of file + name: skill-boost + profiles: + active: local \ No newline at end of file From 46dd6887f7b10b9e443d26eb98343685917c4e98 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Thu, 27 Nov 2025 22:36:32 +0900 Subject: [PATCH 11/21] feat: complete coding test backend implementation --- .gitignore | 5 +- build.gradle | 18 +- .../skillboost/SkillBoostApplication.java | 3 +- .../controller/CodingTestController.java | 54 ++ .../controller/SubmissionController.java | 39 + .../codingtest/domain/CodingProblem.java | 34 + .../codingtest/domain/CodingSubmission.java | 41 + .../codingtest/domain/CodingTestCase.java | 30 + .../codingtest/domain/Difficulty.java | 7 + .../codingtest/dto/ProblemDetailDto.java | 23 + .../codingtest/dto/ProblemSummaryDto.java | 13 + .../codingtest/dto/SubmissionRequestDto.java | 20 + .../codingtest/dto/SubmissionResultDto.java | 22 + .../init/CodingTestDataInitializer.java | 777 ++++++++++++++++++ .../codingtest/judge/GeminiJudge.java | 148 ++++ .../codingtest/judge/JudgeClient.java | 185 +++++ .../codingtest/judge/JudgeResult.java | 55 ++ .../repository/CodingProblemRepository.java | 16 + .../CodingSubmissionRepository.java | 7 + .../repository/CodingTestCaseRepository.java | 15 + .../codingtest/service/CodingTestService.java | 8 + .../codingtest/service/GradingService.java | 29 + src/main/resources/application.yml | 26 +- 23 files changed, 1570 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/skillboost/codingtest/controller/CodingTestController.java create mode 100644 src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingProblem.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/CodingTestCase.java create mode 100644 src/main/java/com/example/skillboost/codingtest/domain/Difficulty.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/ProblemDetailDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/ProblemSummaryDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java create mode 100644 src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/JudgeClient.java create mode 100644 src/main/java/com/example/skillboost/codingtest/judge/JudgeResult.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingProblemRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingSubmissionRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/repository/CodingTestCaseRepository.java create mode 100644 src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java create mode 100644 src/main/java/com/example/skillboost/codingtest/service/GradingService.java diff --git a/.gitignore b/.gitignore index e9574a0..ba43b7a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ .env -*secret.yaml \ No newline at end of file +*secret.yaml + +### Secret Config ### +src/main/resources/application-secret.yml diff --git a/build.gradle b/build.gradle index 710349c..0a4f3e4 100644 --- a/build.gradle +++ b/build.gradle @@ -25,14 +25,28 @@ repositories { } dependencies { + // 기본 Spring Web + JPA + Validation implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // ⭐ AI 요청에 필요한 JSON 처리기 (Jackson) + implementation 'com.fasterxml.jackson.core:jackson-databind' + + // 내장 DB + implementation 'com.h2database:h2' + runtimeOnly 'com.mysql:mysql-connector-j' + + // lombok compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // 개발 환경 developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' - runtimeOnly 'com.mysql:mysql-connector-j' - annotationProcessor 'org.projectlombok:lombok' + + // 테스트 testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/example/skillboost/SkillBoostApplication.java b/src/main/java/com/example/skillboost/SkillBoostApplication.java index 63875ab..d65240e 100644 --- a/src/main/java/com/example/skillboost/SkillBoostApplication.java +++ b/src/main/java/com/example/skillboost/SkillBoostApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; + @SpringBootApplication public class SkillBoostApplication { @@ -10,4 +11,4 @@ public static void main(String[] args) { SpringApplication.run(SkillBoostApplication.class, args); } -} +} \ No newline at end of file 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.yml b/src/main/resources/application.yml index c22d980..49f4f6d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,27 @@ spring: + config: + import: optional:classpath:application-secret.yml application: - name: skill-boost \ No newline at end of file + 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: AIzaSyCQNb1WilrVsCKdZUhfIjk0VdFvHklJyeQ + model: gemini-2.5-flash \ No newline at end of file From a38e274015422f4b5f3248812d6662bd5a0dfa79 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Thu, 27 Nov 2025 23:03:14 +0900 Subject: [PATCH 12/21] fix: coding test issue --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 49f4f6d..61dcf5a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: AIzaSyCQNb1WilrVsCKdZUhfIjk0VdFvHklJyeQ + key: ## model: gemini-2.5-flash \ No newline at end of file From edb40e363406e0e57438c47bb237ab586eda2016 Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Fri, 28 Nov 2025 00:03:42 +0900 Subject: [PATCH 13/21] =?UTF-8?q?fix:=20Base64=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20JWT=20secret=20key=20Update(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 66363b4..d0b8828 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -28,5 +28,5 @@ spring: user-name-attribute: id jwt: - secret-key: test-secret + secret-key: TXlTdXBlclNlY3JldEtleUZvclNraWxsQm9vc3RQcm9qZWN0MjAyNUNoYWxsZW5nZSE= expiration-ms: 100000 From 65bba8abacae05ce6291e48ffa4055bb5acce95b Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 11:22:24 +0900 Subject: [PATCH 14/21] fix: add docker-compose installation step for CI --- .github/workflows/CI.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6670844..131493f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,6 +23,13 @@ jobs: java-version: '21' cache: 'gradle' + # docker-compose(v1) 설치 스텝 + - name: Install docker-compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" \ + -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + # 도커 컴포즈 - name: Start docker-compose run: | From b8ef268f18158b8faed045d12135efd287d5d29f Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 16:21:59 +0900 Subject: [PATCH 15/21] feat: add temporary AI code review using Gemini --- .../controller/CodeReviewController.java | 25 +++ .../codereview/dto/CodeReviewRequest.java | 31 +++ .../codereview/dto/CodeReviewResponse.java | 34 +++ .../llm/GeminiCodeReviewClient.java | 193 ++++++++++++++++++ .../codereview/service/CodeReviewService.java | 9 + .../service/CodeReviewServiceImpl.java | 27 +++ src/main/resources/application.yml | 4 +- 7 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java create mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java create mode 100644 src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java create mode 100644 src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java create mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewService.java create mode 100644 src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java new file mode 100644 index 0000000..d1dc99f --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java @@ -0,0 +1,25 @@ +package com.example.skillboost.codereview.controller; + +import com.example.skillboost.codereview.dto.CodeReviewRequest; +import com.example.skillboost.codereview.dto.CodeReviewResponse; +import com.example.skillboost.codereview.service.CodeReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; + +@CrossOrigin(origins = "http://localhost:3000") +@RestController +@RequestMapping("/api/review") +@RequiredArgsConstructor +public class CodeReviewController { + + private final CodeReviewService codeReviewService; + + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public CodeReviewResponse review(@RequestBody CodeReviewRequest request) { + return codeReviewService.review(request); + } +} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java new file mode 100644 index 0000000..60eb4ac --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java @@ -0,0 +1,31 @@ +package com.example.skillboost.codereview.dto; + +public class CodeReviewRequest { + + private String code; + private String comment; + + public CodeReviewRequest() { + } + + public CodeReviewRequest(String code, String comment) { + this.code = code; + this.comment = comment; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java new file mode 100644 index 0000000..5440b19 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java @@ -0,0 +1,34 @@ +package com.example.skillboost.codereview.dto; + +import java.util.ArrayList; +import java.util.List; + +public class CodeReviewResponse { + + private String review; + private List 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/resources/application.yml b/src/main/resources/application.yml index 61dcf5a..35e1022 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: ## - model: gemini-2.5-flash \ No newline at end of file + key: # + model: gemini-2.5-flash From 0928c0b46fa97e20d549b570a66d46a35a3b094a Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Fri, 28 Nov 2025 16:36:59 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20Redis=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A1=9C=ED=85=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=ED=95=B5=EC=8B=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84(#1?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/skillboost/auth/JwtProvider.java | 41 ++++++++---- .../skillboost/auth/service/TokenService.java | 65 +++++++++++++++++++ .../skillboost/domain/RefreshToken.java | 18 +++++ .../repository/RefreshTokenRepository.java | 10 +++ 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/skillboost/auth/service/TokenService.java create mode 100644 src/main/java/com/example/skillboost/domain/RefreshToken.java create mode 100644 src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index 274ae7a..e0d1432 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -26,7 +26,9 @@ public class JwtProvider { private String secretKeyBase64; @Value("${jwt.expiration-ms}") - private long expirationMs; + private long accessTokenExpirationMs; + + private final long refreshTokenExpirationMs = 14 * 24 * 60 * 60 * 1000L; @PostConstruct protected void init() { @@ -43,17 +45,29 @@ protected void init() { this.key = Keys.hmacShaKeyFor(keyBytes); log.info("JWT Provider 정상 초기화 완료"); } catch (IllegalArgumentException e) { - log.error("Base64 디코딩 실패! 키 값을 확인해주세요. (현재 값: {})", safeKey); + log.error("Base64 디코딩 실패. 키 값을 확인해주세요. (현재 값: {})", safeKey); throw e; } } + /** + * Access Token 생성 (짧은 수명) + */ + public String createAccessToken(String email) { + return createToken(email, accessTokenExpirationMs); + } + /** + * Refresh Token 생성 (긴 수명) + */ + public String createRefreshToken(String email) { + return createToken(email, refreshTokenExpirationMs); + } /** * JWT 토큰 생성 */ - public String createToken(String email) { + public String createToken(String email, long expirationTime) { Date now = new Date(); - Date expiry = new Date(now.getTime() + this.expirationMs); + Date expiry = new Date(now.getTime() + expirationTime); return Jwts.builder() .setSubject(email) @@ -62,6 +76,17 @@ public String createToken(String email) { .signWith(key, SignatureAlgorithm.HS256) .compact(); } + /** + * 토큰에서 사용자 ID(Email) 추출 + */ + public String getUserId(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } /** * JWT 토큰 유효성 검증 @@ -91,13 +116,7 @@ public boolean validateToken(String token) { * JWT 토큰에서 Authentication 객체 생성 */ public Authentication getAuthentication(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - - String email = claims.getSubject(); + String email = getUserId(token); User principal = new User( email, diff --git a/src/main/java/com/example/skillboost/auth/service/TokenService.java b/src/main/java/com/example/skillboost/auth/service/TokenService.java new file mode 100644 index 0000000..66e2a19 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/service/TokenService.java @@ -0,0 +1,65 @@ +package com.example.skillboost.auth.service; + +import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.domain.RefreshToken; +import com.example.skillboost.repository.RefreshTokenRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtProvider jwtProvider; + + /** + * 1. Refresh Token 저장 + */ + @Transactional + public void saveRefreshToken(String userId, String token) { + RefreshToken refreshToken = new RefreshToken(token, userId); + refreshTokenRepository.save(refreshToken); + } + + /** + * 2. 토큰 재발급 (Refresh Token Rotation) + * - 기존 토큰이 유효한지 확인 + * - Redis에 존재하는지 확인 + * - 기존 토큰 삭제 (Rotation) + * - 새 토큰 발급 및 저장 + */ + @Transactional + public String[] rotateTokens(String oldRefreshToken) { + if (!jwtProvider.validateToken(oldRefreshToken)) { + throw new RuntimeException("유효하지 않은 Refresh Token입니다."); + } + + RefreshToken tokenEntity = refreshTokenRepository.findById(oldRefreshToken) + .orElseThrow(() -> new RuntimeException("이미 사용되었거나 존재하지 않는 Refresh Token입니다. 다시 로그인하세요.")); + + refreshTokenRepository.delete(tokenEntity); + + String userId = tokenEntity.getUserId(); + + String newAccessToken = jwtProvider.createAccessToken(userId); + String newRefreshToken = jwtProvider.createRefreshToken(userId); + saveRefreshToken(userId, newRefreshToken); + + log.info("토큰 Rotation 성공, [ User: {} ]", userId); + return new String[]{newAccessToken, newRefreshToken}; + } + + /** + * 3. 로그아웃 (Redis에서 삭제) + */ + @Transactional + public void logout(String refreshToken) { + refreshTokenRepository.findById(refreshToken) + .ifPresent(refreshTokenRepository::delete); + log.info("로그아웃 처리 완료 (Redis 삭제)"); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/domain/RefreshToken.java b/src/main/java/com/example/skillboost/domain/RefreshToken.java new file mode 100644 index 0000000..0b864a0 --- /dev/null +++ b/src/main/java/com/example/skillboost/domain/RefreshToken.java @@ -0,0 +1,18 @@ +package com.example.skillboost.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 1209600) +public class RefreshToken { + @Id + private String refreshToken; + + @Indexed + private String userId; +} diff --git a/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java b/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..4c626ec --- /dev/null +++ b/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.example.skillboost.repository; + +import com.example.skillboost.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByUserId(String userId); +} From 169ebb9afd364a19ae7ebcf6111112fba9cb1d2c Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Fri, 28 Nov 2025 16:37:18 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=EC=97=90=20=ED=86=A0=ED=81=B0=20=EB=A1=9C=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81=EC=9A=A9(#1?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 37 ++++++++++++++++--- .../auth/handler/OAuth2SuccessHandler.java | 21 ++++++----- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/controller/AuthController.java b/src/main/java/com/example/skillboost/auth/controller/AuthController.java index 82dc33f..3b01d86 100644 --- a/src/main/java/com/example/skillboost/auth/controller/AuthController.java +++ b/src/main/java/com/example/skillboost/auth/controller/AuthController.java @@ -1,23 +1,48 @@ package com.example.skillboost.auth.controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.example.skillboost.auth.service.TokenService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + import java.util.Map; -@Tag(name = "깃허브 인증 (Authentication)", description = "소셜 로그인 API") +@Tag(name = "인증 (Authentication)", description = "로그인, 토큰 재발급, 로그아웃") @RestController @RequestMapping("/api/auth") +@RequiredArgsConstructor public class AuthController { + private final TokenService tokenService; @Operation(summary = "GitHub 로그인 URL 반환", description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.") @GetMapping("/github-login-url") public Map getGithubLoginUrl() { - String loginUrl = "/oauth2/authorization/github"; + return Map.of("url", "/oauth2/authorization/github"); + } + + + @Operation(summary = "토큰 재발급 (RTR)", description = "Refresh Token을 헤더에 담아 보내면 새로운 Access/Refresh Token을 발급합니다.") + @PostMapping("/reissue") + public ResponseEntity> reissue(@RequestHeader("RefreshToken") String refreshToken) { + String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken; + String[] newTokens = tokenService.rotateTokens(token); + + return ResponseEntity.ok(Map.of( + "accessToken", newTokens[0], + "refreshToken", newTokens[1] + )); + } + + @Operation(summary = "로그아웃", description = "Redis에서 Refresh Token을 삭제하여 더 이상 사용할 수 없게 만듭니다.") + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("RefreshToken") String refreshToken) { + String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken; + + tokenService.logout(token); - return Map.of("url", loginUrl); + return ResponseEntity.ok("로그아웃 되었습니다."); } } \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java index b484cc8..0aeac80 100644 --- a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -1,6 +1,7 @@ package com.example.skillboost.auth.handler; import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.auth.service.TokenService; import com.example.skillboost.domain.User; import com.example.skillboost.repository.UserRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,6 +25,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final UserRepository userRepository; + private final TokenService tokenService; private final ObjectMapper objectMapper = new ObjectMapper(); @Override @@ -36,31 +38,32 @@ public void onAuthenticationSuccess(HttpServletRequest request, OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); String email = (String) oAuth2User.getAttributes().get("email"); - // GitHub에서 이메일을 비공개로 설정한 경우 처리 if (email == null || email.isEmpty()) { String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); email = githubId + "@github.temp"; log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); } - // Lambda에서 사용하기 위한 final 변수 final String finalEmail = email; - // 사용자 조회 User user = userRepository.findByEmail(finalEmail) .orElseThrow(() -> { log.error("사용자를 찾을 수 없습니다: {}", finalEmail); return new RuntimeException("User not found: " + finalEmail); }); - // JWT 토큰 생성 - String token = jwtProvider.createToken(user.getEmail()); - log.info("JWT 토큰 생성 완료: {}", user.getEmail()); + String accessToken = jwtProvider.createAccessToken(user.getEmail()); + String refreshToken = jwtProvider.createRefreshToken(user.getEmail()); + + tokenService.saveRefreshToken(user.getEmail(), refreshToken); + + log.info("JWT 토큰 생성 및 Redis 저장 완료: {}", user.getEmail()); // JSON 응답 생성 Map responseData = new HashMap<>(); responseData.put("success", true); - responseData.put("token", token); + responseData.put("accessToken", accessToken); + responseData.put("refreshToken", refreshToken); // 프론트엔드에서 저장해야 함 responseData.put("email", user.getEmail()); responseData.put("username", user.getUsername()); @@ -69,7 +72,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, response.setStatus(HttpServletResponse.SC_OK); response.getWriter().write(objectMapper.writeValueAsString(responseData)); - // 프론트엔드로 리다이렉트하려면 아래 주석 해제 - // response.sendRedirect("http://localhost:3000/oauth2/redirect?token=" + token); + // 실제 서비스 배포 시, 사용자를 다시 웹사이트 메인 화면으로 돌려보내기 위해 사용 + // response.sendRedirect("http://localhost:3000/oauth2/redirect?accessToken=" + accessToken + "&refreshToken=" + refreshToken); } } \ No newline at end of file From 171136031026a2b8d6b5cea378bd6507e53ccf56 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 20:44:27 +0900 Subject: [PATCH 18/21] Complete coding test backend --- .../llm/GeminiCodeReviewClient.java | 65 +- .../controller/SubmissionController.java | 13 +- .../codingtest/domain/CodingSubmission.java | 49 +- .../codingtest/dto/SubmissionRequestDto.java | 8 +- .../codingtest/dto/SubmissionResultDto.java | 28 +- .../init/CodingTestDataInitializer.java | 578 +++++++++++++++--- .../codingtest/judge/GeminiJudge.java | 261 +++++--- .../codingtest/service/CodingTestService.java | 14 +- .../codingtest/service/GradingService.java | 20 +- src/main/resources/application.yml | 2 +- 10 files changed, 814 insertions(+), 224 deletions(-) diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java index af2b4a3..90b8680 100644 --- a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java +++ b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java @@ -78,37 +78,40 @@ private String buildPrompt(String code, String comment) { : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; return """ - 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. - 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. - - 🔒 출력 형식 규칙 - - JSON만 출력 (바깥에 설명 절대 금지) - - 마크다운 금지(**, ```, # 등) - - review 항목은: - - 모든 줄을 '□ ' 로 시작 - - 한 줄은 핵심 한 문장만 - - 항목 사이에는 빈 줄(\\n\\n)을 넣기 - - questions 항목은: - - 배열 형태 - - 각 질문은 한 문장 - - 번호(1. 2.)는 넣지 말 것 (번호는 프론트에서 자동 생성됨) - - JSON 예시: - - { - "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", - "questions": [ - "이 코드의 시간 복잡도는 왜 O(N)인가요?", - "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" - ] - } - - 사용자가 요청한 요구사항: - %s - - 리뷰할 코드: - %s - """.formatted(userRequirement, code); + 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. + 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. + + ⚠️ 모든 출력은 반드시 한국어로 작성해. + 마크다운 금지(**, ```, # 등) + JSON 외 텍스트 출력 금지. + + 🔒 출력 형식 규칙 + - review 항목은: + - 모든 줄을 '□ ' 로 시작 + - 한 줄은 핵심 한 문장 + - 항목 사이에는 빈 줄(\\n\\n) 있어야 함 + + - questions 항목은: + - 배열 형태 + - 각 질문은 한국어 한 문장 + - 번호(1. 2.)는 넣지 말 것 + + JSON 예시: + + { + "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", + "questions": [ + "이 코드에서 개선할 수 있는 부분은 무엇인가요?", + "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" + ] + } + + 사용자가 요청한 요구사항: + %s + + 리뷰할 코드: + %s + """.formatted(userRequirement, code); } /** diff --git a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java index df2ee8c..d16b355 100644 --- a/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -17,13 +17,16 @@ public class SubmissionController { private final GradingService gradingService; + /** + * 코딩 테스트 제출 + 채점 + * POST /api/coding/submissions + */ @PostMapping("/submissions") - public ResponseEntity submitCode(@RequestBody SubmissionRequestDto request) { + public ResponseEntity submit(@RequestBody SubmissionRequestDto request) { + log.info("코딩테스트 제출 요청: problemId={}, language={}, userId={}", + request.getProblemId(), request.getLanguage(), request.getUserId()); - log.info("채점 요청 도착: problemId={}, language={}", - request.getProblemId(), request.getLanguage()); - - if (request.getCode() == null || request.getCode().isEmpty()) { + if (request.getCode() == null || request.getCode().isBlank()) { return ResponseEntity.badRequest().body( SubmissionResultDto.builder() .status("ERROR") diff --git a/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java index a64dff4..7a07fef 100644 --- a/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; import lombok.*; + import java.time.LocalDateTime; @Entity @@ -10,32 +11,60 @@ @NoArgsConstructor @AllArgsConstructor @Builder +@Table(name = "coding_submission") public class CodingSubmission { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "problem_id") - private CodingProblem problem; + // 문제 ID + @Column(nullable = false) + private Long problemId; - // ★ [추가] 누가 풀었는지 저장해야 합니다! + // 유저 ID + @Column(nullable = false) private Long userId; + // 사용 언어 (python / java / cpp ...) + @Column(length = 20) private String language; - @Column(columnDefinition = "TEXT") + // 제출 코드 + @Lob + @Column(nullable = false) private String sourceCode; - private String verdict; - private int passedCount; - private int totalCount; + // "AC", "WA", "PARTIAL", "ERROR" 등 + @Column(length = 20) + private String status; + + // 0 ~ 100 점 + private Integer score; + + // 통과/전체 테스트 수 + private Integer passedCount; + private Integer totalCount; + + // 간단 메시지 + @Column(length = 255) + private String message; + + // 🔹 AI 코드 리뷰 (TEXT) + @Lob + private String aiFeedback; + + // 🔥 예상 면접 질문 (JSON 문자열로 저장) + @Lob + private String interviewQuestionsJson; + // 생성 시각 private LocalDateTime createdAt; @PrePersist public void onCreate() { - this.createdAt = LocalDateTime.now(); + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } } -} \ 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 index c2c6149..aa43aaa 100644 --- a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java @@ -4,17 +4,23 @@ import lombok.Data; import lombok.NoArgsConstructor; +/** + * 프론트에서 /api/coding/submissions 로 보내는 요청 DTO + */ @Data @NoArgsConstructor public class SubmissionRequestDto { + // 문제 ID private Long problemId; - // 프론트에서 보내는 JSON 키: "sourceCode" + // 프론트 JSON 키: "sourceCode" -> 여기로 매핑 @JsonProperty("sourceCode") private String code; + // 사용 언어 (python / java / cpp ...) private String language; + // 유저 ID (없으면 1로 기본값 줄 수도 있음) 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 index b1f67bf..119af77 100644 --- a/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java @@ -5,18 +5,34 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.List; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class SubmissionResultDto { + private Long submissionId; - private String status; // "AC"(정답), "WA"(오답) - private Integer score; // 0 ~ 100점 - private Integer passedCount; // (AI 추정치) + + // "AC"(정답), "WA"(오답) 등 + private String status; + + // 0 ~ 100점 + private Integer score; + + // 통과한 테스트케이스 수 (없으면 null 가능) + private Integer passedCount; + + // 전체 테스트케이스 수 (없으면 null 가능) private Integer totalCount; - private String message; // "정답입니다!" 같은 간단 메시지 - // ★ [추가] AI 선생님의 상세 피드백 + // "정답입니다! 🎉" 같은 간단 메시지 + private String message; + + // 🔹 AI 코드 리뷰 텍스트 private String aiFeedback; -} \ No newline at end of file + + // 🔥 예상 면접 질문 리스트 (프론트에서 1. 2. 3. 으로 뿌려줌) + private List interviewQuestions; +} diff --git a/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java index b20d889..255149b 100644 --- a/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java +++ b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java @@ -15,26 +15,41 @@ public class CodingTestDataInitializer implements CommandLineRunner { @Override public void run(String... args) { - // EASY (5문제) + // ========================= + // EASY 문제들 + // ========================= createExamSupervisorProblem(); // 시험 감독 createZoacDistancingProblem(); // ZOAC 거리두기 createDjmaxRankingProblem(); // DJMAX 랭킹 createMinHeapProblem(); // 최소 힙 createTriangleProblem(); // 삼각형 분류 + createMakeOneProblem(); // 1로 만들기 + createNumberCardProblem(); // 숫자 카드 - // MEDIUM (5문제) + // ========================= + // MEDIUM 문제들 + // ========================= createSnakeGameProblem(); // Dummy (뱀 게임) createDiceSimulationProblem(); // 주사위 굴리기 createTargetDistanceProblem(); // 목표지점 거리 createDfsBfsProblem(); // DFS와 BFS - createTripPlanningProblem(); // 여행 가자 (New) - - // HARD (5문제) + createTripPlanningProblem(); // 여행 가자 + createChristmasGiftProblem(); // 크리스마스 선물 + createCardBuyingProblem(); // 카드 구매하기 + createFireEscapeProblem(); // 불! + + // ========================= + // HARD 문제들 + // ========================= createMarbleEscapeProblem(); // 구슬 탈출 createSharkCopyMagicProblem(); // 마법사 상어와 복제 createSimilarWordsProblem(); // 비슷한 단어 createJewelThiefProblem(); // 보석 도둑 - createMarsExplorationProblem(); // 화성 탐사 (New) + createMarsExplorationProblem(); // 화성 탐사 + createLectureTourProblem(); // 순회강연 + createLectureRoomAssignmentProblem(); // 강의실 배정 + createPopulationMovementProblem(); // 인구 이동 + createPrisonBreakProblem(); // 탈옥 } // ========================= @@ -240,7 +255,7 @@ private void createTriangleProblem() { Equilateral : 세 변의 길이가 모두 같은 경우 Isosceles : 두 변의 길이만 같은 경우 Scalene : 세 변의 길이가 모두 다른 경우 - + 단 주어진 세 변의 길이가 삼각형의 조건을 만족하지 못하는 경우에는 "Invalid" 를 출력한다. 예를 들어 6, 3, 2가 이 경우에 해당한다. 가장 긴 변의 길이보다 나머지 두 변의 길이의 합이 길지 않으면 삼각형의 조건을 만족하지 못한다. @@ -269,11 +284,90 @@ private void createTriangleProblem() { problemRepository.save(problem); } + // 6. 1로 만들기 + private void createMakeOneProblem() { + if (problemRepository.existsByTitle("1로 만들기")) { + return; + } + + String description = """ + [문제] + + 정수 X에 사용할 수 있는 연산은 다음과 같이 세 가지 이다. + + 1. X가 3으로 나누어 떨어지면, 3으로 나눈다. + 2. X가 2로 나누어 떨어지면, 2로 나눈다. + 3. 1을 뺀다. + + 정수 N이 주어졌을 때, 위와 같은 연산 세 개를 적절히 사용해서 1을 만들려고 한다. + 연산을 사용하는 횟수의 최솟값을 출력하시오. + + + [입력] + + 첫째 줄에 1보다 크거나 같고, 10^6보다 작거나 같은 정수 N이 주어진다. + + + [출력] + + 첫째 줄에 연산을 하는 횟수의 최솟값을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("1로 만들기") + .difficulty(Difficulty.EASY) + .description(description) + .tags("dp") + .build(); + + problemRepository.save(problem); + } + + // 7. 숫자 카드 + private void createNumberCardProblem() { + if (problemRepository.existsByTitle("숫자 카드")) { + return; + } + + String description = """ + [문제] + + 숫자 카드는 정수 하나가 적혀져 있는 카드이다. 상근이는 숫자 카드 N개를 가지고 있다. + 정수 M개가 주어졌을 때, 이 수가 적혀있는 숫자 카드를 상근이가 가지고 있는지 아닌지를 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 상근이가 가지고 있는 숫자 카드의 개수 N(1 ≤ N ≤ 500,000)이 주어진다. + 둘째 줄에는 숫자 카드에 적혀있는 정수가 주어진다. + 숫자 카드에 적혀있는 수는 -10,000,000보다 크거나 같고, 10,000,000보다 작거나 같다. + 두 숫자 카드에 같은 수가 적혀있는 경우는 없다. + + 셋째 줄에는 M(1 ≤ M ≤ 500,000)이 주어진다. + 넷째 줄에는 상근이가 가지고 있는 숫자 카드인지 아닌지를 구해야 할 M개의 정수가 주어진다. + + + [출력] + + 첫째 줄에 입력으로 주어진 M개의 수에 대해서, + 각 수가 적힌 숫자 카드를 상근이가 가지고 있으면 1을, 아니면 0을 공백으로 구분해 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("숫자 카드") + .difficulty(Difficulty.EASY) + .description(description) + .tags("binary_search,set,implementation") + .build(); + + problemRepository.save(problem); + } + // ========================= // MEDIUM 문제들 // ========================= - // 6. Dummy (뱀 게임) + // 8. Dummy (뱀 게임) private void createSnakeGameProblem() { if (problemRepository.existsByTitle("Dummy (뱀 게임)")) { return; @@ -335,7 +429,7 @@ private void createSnakeGameProblem() { problemRepository.save(problem); } - // 7. 주사위 굴리기 + // 9. 주사위 굴리기 private void createDiceSimulationProblem() { if (problemRepository.existsByTitle("주사위 굴리기")) { return; @@ -404,7 +498,7 @@ private void createDiceSimulationProblem() { problemRepository.save(problem); } - // 8. 목표지점 거리 + // 10. 목표지점 거리 private void createTargetDistanceProblem() { if (problemRepository.existsByTitle("목표지점 거리")) { return; @@ -416,15 +510,19 @@ private void createTargetDistanceProblem() { 지도가 주어지면 모든 지점에 대해서 목표지점까지의 거리를 구하여라. 문제를 쉽게 만들기 위해 오직 가로와 세로로만 움직일 수 있다고 하자. + [입력] - 지도의 크기 n과 m이 주어진다. n은 세로의 크기, m은 가로의 크기다.(2 ≤ n ≤ 1000, 2 ≤ m ≤ 1000) - 다음 n개의 줄에 m개의 숫자가 주어진다. 0은 갈 수 없는 땅이고 1은 갈 수 있는 땅, 2는 목표지점이다. 입력에서 2는 단 한개이다. + 지도의 크기 n과 m이 주어진다. n은 세로의 크기, m은 가로의 크기다. (2 ≤ n ≤ 1000, 2 ≤ m ≤ 1000) + 다음 n개의 줄에 m개의 숫자가 주어진다. + 0은 갈 수 없는 땅이고 1은 갈 수 있는 땅, 2는 목표지점이다. 입력에서 2는 단 한 개이다. + [출력] 각 지점에서 목표지점까지의 거리를 출력한다. - 원래 갈 수 없는 땅인 위치는 0을 출력하고, 원래 갈 수 있는 땅인 부분 중에서 도달할 수 없는 위치는 -1을 출력한다. + 원래 갈 수 없는 땅인 위치는 0을 출력하고, + 원래 갈 수 있는 땅인 부분 중에서 도달할 수 없는 위치는 -1을 출력한다. """; CodingProblem problem = CodingProblem.builder() @@ -437,7 +535,7 @@ private void createTargetDistanceProblem() { problemRepository.save(problem); } - // 9. DFS와 BFS + // 11. DFS와 BFS private void createDfsBfsProblem() { if (problemRepository.existsByTitle("DFS와 BFS")) { return; @@ -475,7 +573,7 @@ private void createDfsBfsProblem() { problemRepository.save(problem); } - // 10. 여행 가자 (New) + // 12. 여행 가자 private void createTripPlanningProblem() { if (problemRepository.existsByTitle("여행 가자")) { return; @@ -484,25 +582,33 @@ private void createTripPlanningProblem() { 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이 주어진다. N은 200이하이다. 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. M은 1000이하이다. - 다음 N개의 줄에는 N개의 정수가 주어진다. i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. - 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. A와 B가 연결되었으면 B와 A도 연결되어 있다. - 마지막 줄에는 여행 계획이 주어진다. 도시의 번호는 1부터 N까지 차례대로 매겨져 있다. + 첫 줄에 도시의 수 N이 주어진다. (N ≤ 200) + 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. (M ≤ 1000) + + 다음 N개의 줄에는 N개의 정수가 주어진다. + i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. + 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. + + 마지막 줄에는 여행 계획이 주어진다. + 도시의 번호는 1부터 N까지 차례대로 매겨져 있다. [출력] - 첫 줄에 가능하면 YES 불가능하면 NO를 출력한다. + 첫 줄에 가능하면 YES, 불가능하면 NO를 출력한다. """; CodingProblem problem = CodingProblem.builder() @@ -515,11 +621,163 @@ private void createTripPlanningProblem() { problemRepository.save(problem); } + // 13. 크리스마스 선물 + private void createChristmasGiftProblem() { + if (problemRepository.existsByTitle("크리스마스 선물")) { + return; + } + + String description = """ + [문제] + + 크리스마스에는 산타가 착한 아이들에게 선물을 나눠준다. + 올해도 산타는 선물을 나눠주기 위해 전 세계를 돌아다니며 착한 아이들에게 선물을 나눠줄 것이다. + 하지만 산타의 썰매는 그렇게 크지 않기 때문에, + 세계 곳곳에 거점들을 세워 그 곳을 방문하며 선물을 충전해 나갈 것이다. + + 또한, 착한 아이들을 만날 때마다 자신이 들고 있는 가장 가치가 큰 선물 하나를 선물해 줄 것이다. + + 이제 산타가 선물을 나눠줄 것이다. + 차례대로 방문한 아이들과 거점지의 정보들이 주어졌을 때, + 아이들이 받은 선물들의 가치를 출력하시오. + 만약 아이들에게 줄 선물이 없다면 -1을 출력하시오. + + + [입력] + + 첫 번째 줄에는 아이들과 거점지를 방문한 횟수 n이 주어진다. (1 ≤ n ≤ 5,000) + + 다음 n줄에는 먼저 정수 a가 주어지고, 그 다음 a개의 숫자가 주어진다. + a > 0 이라면 거점지에서 a개의 선물을 충전하는 것이고, + 이 숫자들이 선물의 가치이다. + 만약 a가 0이라면 거점지가 아닌 아이들을 만난 것이다. + + 선물의 가치는 100,000보다 작은 양의 정수이다. (1 ≤ a ≤ 100) + + + [출력] + + a가 0일 때마다, 아이들에게 준 선물의 가치를 출력하시오. + 만약 줄 선물이 없다면 -1을 출력하라. + 적어도 하나의 출력이 있음을 보장한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("크리스마스 선물") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("datastructure,heap,priority_queue") + .build(); + + problemRepository.save(problem); + } + + // 14. 카드 구매하기 + private void createCardBuyingProblem() { + if (problemRepository.existsByTitle("카드 구매하기")) { + return; + } + + String description = """ + [문제] + + 요즘 민규네 동네에서는 스타트링크에서 만든 PS카드를 모으는 것이 유행이다. + + PS카드는 PS(Problem Solving) 분야에서 유명한 사람들의 아이디와 얼굴이 적혀있는 카드이다. + 각각의 카드에는 등급을 나타내는 색이 칠해져 있고, 다음과 같이 8가지가 있다. + + 전설카드, 레드카드, 오렌지카드, 퍼플카드, + 블루카드, 청록카드, 그린카드, 그레이카드 + + 카드는 카드팩의 형태로만 구매할 수 있고, + 카드팩의 종류는 카드 1개가 포함된 카드팩, 카드 2개가 포함된 카드팩, ... + 카드 N개가 포함된 카드팩과 같이 총 N가지가 존재한다. + + 민규는 카드의 개수가 적은 팩이더라도 가격이 비싸면 높은 등급의 카드가 많이 들어있을 것이라는 미신을 믿고 있다. + 따라서, 민규는 돈을 최대한 많이 지불해서 카드 N개를 구매하려고 한다. + + 카드가 i개 포함된 카드팩의 가격은 Pi원이다. + + + [입력] + + 첫째 줄에 민규가 구매하려고 하는 카드의 개수 N이 주어진다. (1 ≤ N ≤ 1,000) + 둘째 줄에는 P1부터 PN까지 카드팩의 가격 Pi가 주어진다. (1 ≤ Pi ≤ 10,000) + + + [출력] + + 민규가 카드 N개를 갖기 위해 지불해야 하는 금액의 최댓값을 출력한다. + 구매한 카드팩에 포함된 카드 개수의 합은 정확히 N과 같아야 한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("카드 구매하기") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("dp,knapsack") + .build(); + + problemRepository.save(problem); + } + + // 15. 불! + private void createFireEscapeProblem() { + if (problemRepository.existsByTitle("불!")) { + return; + } + + String description = """ + [문제] + + 상근이는 빈 공간과 벽으로 이루어진 건물에 갇혀있다. + 건물의 일부에는 불이 났고, 상근이는 출구를 향해 뛰고 있다. + + 매 초마다, 불은 동서남북 방향으로 인접한 빈 공간으로 퍼져나간다. + 벽에는 불이 붙지 않는다. + 상근이는 동서남북 인접한 칸으로 이동할 수 있으며, 1초가 걸린다. + 상근이는 벽을 통과할 수 없고, + 불이 옮겨진 칸 또는 이제 불이 붙으려는 칸으로 이동할 수 없다. + 상근이가 있는 칸에 불이 옮겨옴과 동시에 다른 칸으로 이동할 수 있다. + + 빌딩의 지도가 주어졌을 때, + 얼마나 빨리 빌딩을 탈출할 수 있는지 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 테스트 케이스의 개수가 주어진다. (최대 100개) + + 각 테스트 케이스의 첫째 줄에는 빌딩 지도의 너비 w와 높이 h가 주어진다. (1 ≤ w, h ≤ 1000) + 다음 h개 줄에는 w개의 문자로 빌딩의 지도가 주어진다. + + '.' : 빈 공간 + '#' : 벽 + '@' : 상근이의 시작 위치 + '*' : 불 + + + [출력] + + 각 테스트 케이스마다 빌딩을 탈출하는데 가장 빠른 시간을 출력한다. + 빌딩을 탈출할 수 없는 경우에는 "IMPOSSIBLE"을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("불!") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("bfs,graph,multi_source_bfs") + .build(); + + problemRepository.save(problem); + } + // ========================= // HARD 문제들 // ========================= - // 11. 구슬 탈출 + // 16. 구슬 탈출 (이미 기존에 있던 문제) private void createMarbleEscapeProblem() { if (problemRepository.existsByTitle("구슬 탈출")) { return; @@ -537,7 +795,7 @@ private void createMarbleEscapeProblem() { 빨간 구슬과 파란 구슬의 크기는 보드에서 1×1 크기의 칸을 가득 채우는 사이즈이고, 각각 하나씩 들어가 있다. - 이때, 구슬을 손으로 건드릴 수는 없고, 중력을 이용해서 이리 저리 굴려야 한다. + 이때, 구슬을 손으로 건드릴 수는 없고, 중력을 이용해서 이리저리 굴려야 한다. 왼쪽으로 기울이기, 오른쪽으로 기울이기, 위쪽으로 기울이기, 아래쪽으로 기울이기와 같은 네 가지 동작이 가능하다. @@ -581,7 +839,7 @@ private void createMarbleEscapeProblem() { problemRepository.save(problem); } - // 12. 마법사 상어와 복제 + // 17. 마법사 상어와 복제 private void createSharkCopyMagicProblem() { if (problemRepository.existsByTitle("마법사 상어와 복제")) { return; @@ -590,53 +848,29 @@ private void createSharkCopyMagicProblem() { String description = """ [문제] - 마법사 상어는 파이어볼, 토네이도, 파이어스톰, 물복사버그, 비바라기, 블리자드 마법을 할 수 있다. - 오늘은 기존에 배운 물복사버그 마법의 상위 마법인 복제를 배웠고, + 마법사 상어는 물복사버그 마법의 상위 마법인 복제를 배웠고, 4 × 4 크기의 격자에서 연습하려고 한다. - (r, c)는 격자의 r행 c열을 의미한다. - 격자의 가장 왼쪽 윗 칸은 (1, 1)이고, 가장 오른쪽 아랫 칸은 (4, 4)이다. 격자에는 물고기 M마리가 있다. 각 물고기는 격자의 칸 하나에 들어가 있으며, 이동 방향을 가지고 있다. 이동 방향은 8가지 방향(상하좌우, 대각선) 중 하나이다. - 마법사 상어도 연습을 위해 격자에 들어가있다. - 상어도 격자의 한 칸에 들어가있다. - 둘 이상의 물고기가 같은 칸에 있을 수도 있으며, - 마법사 상어와 물고기가 같은 칸에 있을 수도 있다. + 마법사 상어도 연습을 위해 격자에 들어가 있다. 상어의 마법 연습 한 번은 다음과 같은 작업이 순차적으로 이루어진다. 1. 상어가 모든 물고기에게 복제 마법을 시전한다. - 복제 마법은 시간이 조금 걸리기 때문에, 아래 5번에서 물고기가 복제되어 나타난다. - 2. 모든 물고기가 한 칸 이동한다. - 상어가 있는 칸, 물고기의 냄새가 있는 칸, 격자의 범위를 벗어나는 칸으로는 이동할 수 없다. - 각 물고기는 자신이 가지고 있는 이동 방향이 이동할 수 있는 칸을 향할 때까지 - 방향을 45도 반시계 회전시킨다. - 이동할 수 있는 칸이 없으면 이동하지 않는다. - - 3. 상어가 연속해서 3칸 이동한다. - 상어는 상하좌우로 인접한 칸으로 이동할 수 있다. - 이동 중 격자를 벗어나면 그 방법은 불가능하다. - 이동 중 물고기가 있는 칸에 도착하면, 그 칸의 모든 물고기는 제거되고 냄새를 남긴다. - 가능한 이동 방법 중 제거되는 물고기가 가장 많은 방법을 선택하며, - 동일하다면 사전순으로 가장 앞서는 방법을 선택한다. - + 3. 상어가 연속해서 3칸 이동하면서 물고기를 먹고 냄새를 남긴다. 4. 두 번 전 연습에서 생긴 물고기의 냄새가 격자에서 사라진다. - 5. 1에서 사용된 복제 마법이 완료되어 복제된 물고기가 생성된다. [입력] 첫째 줄에 물고기의 수 M, 연습 횟수 S가 주어진다. - 다음 M개의 줄에는 물고기의 정보 (fx, fy, d)가 주어지며, - d는 1~8 방향을 의미한다. (←, ↖, ↑, ↗, →, ↘, ↓, ↙) - + 다음 M개의 줄에는 물고기의 정보 (fx, fy, d)가 주어진다. 마지막 줄에는 상어의 위치 (sx, sy)가 주어진다. - 격자 위에 있는 물고기의 수가 항상 1,000,000 이하인 입력만 주어진다. - [출력] @@ -653,7 +887,7 @@ private void createSharkCopyMagicProblem() { problemRepository.save(problem); } - // 13. 비슷한 단어 + // 18. 비슷한 단어 private void createSimilarWordsProblem() { if (problemRepository.existsByTitle("비슷한 단어")) { return; @@ -667,12 +901,6 @@ private void createSimilarWordsProblem() { 두 단어의 비슷한 정도는 두 단어의 접두사의 길이로 측정한다. 접두사란 두 단어의 앞부분에서 공통적으로 나타나는 부분문자열을 말한다. 즉, 두 단어의 앞에서부터 M개의 글자들이 같으면서 M이 최대인 경우를 구하는 것이다. - "AHEHHEH", "AHAHEH"의 접두사는 "AH"가 되고, "AB", "CD"의 접두사는 ""(길이가 0)이 된다. - - 접두사의 길이가 최대인 경우가 여러 개일 때에는 입력되는 순서대로 제일 앞쪽에 있는 단어를 답으로 한다. - 즉, 답으로 S라는 문자열과 T라는 문자열을 출력한다고 했을 때, - 우선 S가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력하고, - 그런 경우도 여러 개 있을 때에는 그 중에서 T가 입력되는 순서대로 제일 앞쪽에 있는 단어인 경우를 출력한다. [입력] @@ -683,8 +911,8 @@ private void createSimilarWordsProblem() { [출력] - 첫째 줄에 S를, 둘째 줄에 T를 출력한다. - 단, 이 두 단어는 서로 달라야 한다. 즉, 가장 비슷한 두 단어를 구할 때 같은 단어는 제외하는 것이다. + 가장 비슷한 두 단어 S, T를 한 줄에 하나씩 출력한다. + 두 단어는 서로 달라야 한다. """; CodingProblem problem = CodingProblem.builder() @@ -697,7 +925,7 @@ private void createSimilarWordsProblem() { problemRepository.save(problem); } - // 14. 보석 도둑 + // 19. 보석 도둑 private void createJewelThiefProblem() { if (problemRepository.existsByTitle("보석 도둑")) { return; @@ -707,23 +935,26 @@ private void createJewelThiefProblem() { [문제] 세계적인 도둑 상덕이는 보석점을 털기로 결심했다. - 상덕이가 털 보석점에는 보석이 총 N개 있다. 각 보석은 무게 Mi와 가격 Vi를 가지고 있다. - 상덕이는 가방을 K개 가지고 있고, 각 가방에 담을 수 있는 최대 무게는 Ci이다. + 상덕이가 털 보석점에는 보석이 총 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) - 모든 숫자는 양의 정수이다. + 다음 N개 줄에는 각 보석의 정보 Mi와 Vi가 주어진다. + 다음 K개 줄에는 가방에 담을 수 있는 최대 무게 Ci가 주어진다. [출력] - 첫째 줄에 상덕이가 훔칠 수 있는 보석 가격의 합의 최댓값을 출력한다. + 상덕이가 훔칠 수 있는 보석 가격의 합의 최댓값을 출력한다. """; CodingProblem problem = CodingProblem.builder() @@ -736,7 +967,7 @@ private void createJewelThiefProblem() { problemRepository.save(problem); } - // 15. 화성 탐사 (New) + // 20. 화성 탐사 private void createMarsExplorationProblem() { if (problemRepository.existsByTitle("화성 탐사")) { return; @@ -745,24 +976,29 @@ private void createMarsExplorationProblem() { String description = """ [문제] - NASA에서는 화성 탐사를 위해 화성에 무선 조종 로봇을 보냈다. 실제 화성의 모습은 굉장히 복잡하지만, - 로봇의 메모리가 얼마 안 되기 때문에 지형을 N×M 배열로 단순화 하여 생각하기로 한다. - 지형의 고저차의 특성상, 로봇은 움직일 때 배열에서 왼쪽, 오른쪽, 아래쪽으로 이동할 수 있지만, 위쪽으로는 이동할 수 없다. - 또한 한 번 탐사한 지역(배열에서 하나의 칸)은 탐사하지 않기로 한다. + NASA에서는 화성 탐사를 위해 화성에 무선 조종 로봇을 보냈다. + 실제 화성의 모습은 복잡하지만, + 로봇의 메모리가 적기 때문에 지형을 N×M 배열로 단순화하여 생각하기로 한다. + + 지형의 특성상, 로봇은 배열에서 왼쪽, 오른쪽, 아래쪽으로 이동할 수 있지만, + 위쪽으로는 이동할 수 없다. + 또한 한 번 탐사한 지역은 다시 탐사하지 않는다. - 각각의 지역은 탐사 가치가 있는데, 로봇을 배열의 왼쪽 위 (1, 1)에서 출발시켜 오른쪽 아래 (N, M)으로 보내려고 한다. - 이때, 위의 조건을 만족하면서, 탐사한 지역들의 가치의 합이 최대가 되도록 하는 프로그램을 작성하시오. + 각각의 지역은 탐사 가치가 있는데, + 로봇을 배열의 왼쪽 위 (1, 1)에서 출발시켜 오른쪽 아래 (N, M)으로 보내려고 한다. + 이때, 위의 조건을 만족하면서 탐사한 지역들의 가치 합의 최댓값을 구하는 프로그램을 작성하시오. [입력] - 첫째 줄에 N, M(1≤N, M≤1,000)이 주어진다. 다음 N개의 줄에는 M개의 수로 배열이 주어진다. - 배열의 각 수는 절댓값이 100을 넘지 않는 정수이다. 이 값은 그 지역의 가치를 나타낸다. + 첫째 줄에 N, M(1 ≤ N, M ≤ 1,000)이 주어진다. + 다음 N개의 줄에는 M개의 정수로 배열이 주어진다. + 각 값은 그 지역의 가치를 나타내며, 절댓값이 100을 넘지 않는다. [출력] - 첫째 줄에 최대 가치의 합을 출력한다. + 최대 가치의 합을 출력한다. """; CodingProblem problem = CodingProblem.builder() @@ -774,4 +1010,180 @@ private void createMarsExplorationProblem() { problemRepository.save(problem); } -} \ No newline at end of file + + // 21. 순회강연 + private void createLectureTourProblem() { + if (problemRepository.existsByTitle("순회강연")) { + return; + } + + String description = """ + [문제] + + 한 저명한 학자에게 n(0 ≤ n ≤ 10,000)개의 대학에서 강연 요청을 해 왔다. + 각 대학에서는 d(1 ≤ d ≤ 10,000)일 안에 와서 강연을 해 주면 + p(1 ≤ p ≤ 10,000)만큼의 강연료를 지불하겠다고 알려왔다. + + 이 학자는 하루에 최대 한 곳에서만 강연을 할 수 있고, + 가장 많은 돈을 벌 수 있도록 일정표를 짜려고 한다. + + + [입력] + + 첫째 줄에 정수 n이 주어진다. + 다음 n개의 줄에는 각 대학에서 제시한 p와 d가 주어진다. + + + [출력] + + 첫째 줄에 최대로 벌 수 있는 돈을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("순회강연") + .difficulty(Difficulty.HARD) + .description(description) + .tags("greedy,priority_queue,sorting") + .build(); + + problemRepository.save(problem); + } + + // 22. 강의실 배정 + private void createLectureRoomAssignmentProblem() { + if (problemRepository.existsByTitle("강의실 배정")) { + return; + } + + String description = """ + [문제] + + N개의 강의가 있다. 우리는 모든 강의의 시작 시간과 끝나는 시간을 알고 있다. + 이때, 우리는 최대한 적은 수의 강의실을 사용하여 + 모든 강의가 이루어지게 하고 싶다. + + 한 강의실에서는 동시에 2개 이상의 강의를 진행할 수 없고, + 한 강의의 종료 시간과 다른 강의의 시작 시간이 겹치는 것은 상관없다. + + 필요한 최소 강의실의 수를 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 강의의 개수 N(1 ≤ N ≤ 100,000)이 주어진다. + 둘째 줄부터 N개의 줄에 걸쳐 각 줄마다 세 개의 정수가 주어진다. + 각 줄은 강의 번호, 강의 시작 시간, 강의 종료 시간을 의미한다. + 시작 시간과 종료 시간은 0 이상 10억 이하의 정수이고, 시작 < 종료 이다. + + + [출력] + + 필요한 최소 강의실 개수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("강의실 배정") + .difficulty(Difficulty.HARD) + .description(description) + .tags("greedy,priority_queue,sorting") + .build(); + + problemRepository.save(problem); + } + + // 23. 인구 이동 + private void createPopulationMovementProblem() { + if (problemRepository.existsByTitle("인구 이동")) { + return; + } + + String description = """ + [문제] + + N×N 크기의 땅이 있고, 각각의 칸에는 나라가 하나씩 있다. + r행 c열에 있는 나라에는 A[r][c]명이 살고 있다. + + 인접한 나라 사이에는 국경선이 존재하며, + 두 나라의 인구 차이가 L명 이상, R명 이하라면 + 국경선을 오늘 하루 동안 연다. + + 국경선이 열려있어 인접한 칸만을 이용해 이동할 수 있는 나라들을 연합이라고 하고, + 연합을 이루고 있는 각 칸의 인구수는 + (연합의 인구수 합) / (연합을 이루는 칸의 개수)로 바뀐다. (소수점 버림) + + 더 이상 인구 이동이 일어나지 않을 때까지 반복될 때, + 인구 이동이 며칠 동안 발생하는지 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N, L, R이 주어진다. (1 ≤ N ≤ 50, 1 ≤ L ≤ R ≤ 100) + 둘째 줄부터 N개의 줄에 각 나라의 인구수가 주어진다. (0 ≤ A[r][c] ≤ 100) + + + [출력] + + 인구 이동이 발생한 일수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("인구 이동") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,bfs,graph") + .build(); + + problemRepository.save(problem); + } + + // 24. 탈옥 + private void createPrisonBreakProblem() { + if (problemRepository.existsByTitle("탈옥")) { + return; + } + + String description = """ + [문제] + + 상근이는 감옥에서 죄수 두 명을 탈옥시켜야 한다. + 감옥은 1층짜리 건물이고, 감옥의 평면도가 주어진다. + + 평면도에는 모든 벽과 문이 나타나 있고, 죄수의 위치도 나타나 있다. + 감옥은 무인 감옥으로 죄수 두 명이 감옥에 있는 유일한 사람이다. + + 문은 중앙 제어실에서만 열 수 있지만, + 상근이는 특별한 기술을 이용해 제어실을 통하지 않고 문을 열려고 한다. + 하지만 문을 열려면 시간이 매우 많이 걸리기 때문에, + 두 죄수를 탈옥시키기 위해 열어야 하는 문의 개수를 최소화하려고 한다. + + 문을 한 번 열면 계속 열린 상태로 있는다. + + + [입력] + + 첫째 줄에 테스트 케이스의 개수가 주어진다. (100 이하) + + 각 테스트 케이스의 첫 줄에는 평면도의 높이 h와 너비 w가 주어진다. (2 ≤ h, w ≤ 100) + 다음 h개 줄에는 감옥의 평면도 정보가 주어지며, + 빈 공간은 '.', 벽은 '*', 문은 '#', 죄수는 '$'로 주어진다. + + 상근이는 감옥 밖을 자유롭게 이동할 수 있고, + 평면도에 표시된 죄수의 수는 항상 두 명이다. + + + [출력] + + 각 테스트 케이스마다 두 죄수를 탈옥시키기 위해서 + 열어야 하는 문의 최솟값을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("탈옥") + .difficulty(Difficulty.HARD) + .description(description) + .tags("graph,bfs,0_1_bfs") + .build(); + + problemRepository.save(problem); + } +} diff --git a/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java index bec9308..ecb286b 100644 --- a/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java +++ b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java @@ -4,6 +4,8 @@ import com.example.skillboost.codingtest.dto.SubmissionResultDto; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -11,138 +13,237 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import java.util.ArrayList; import java.util.List; -import java.util.Map; +/** + * Gemini API를 이용해 + * - 사용자가 작성한 코드를 채점하고 + * - 한국어 코드 리뷰(aiFeedback) + * - 예상 면접 질문(interviewQuestions) + * 을 생성하는 Judge. + */ @Slf4j @Component @RequiredArgsConstructor public class GeminiJudge { + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate = new RestTemplate(); + @Value("${gemini.api.key}") private String apiKey; - @Value("${gemini.model}") + @Value("${gemini.model:gemini-2.0-flash}") private String model; - private final ObjectMapper objectMapper; - + /** + * AI 채점 메인 로직 + */ 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) - )) - ) - ); + // 1) 프롬프트 만들기 + String prompt = buildPrompt(problem, language, userCode); + + // 2) Gemini 요청 바디 만들기 + ObjectNode root = objectMapper.createObjectNode(); + ArrayNode contents = objectMapper.createArrayNode(); + ObjectNode content = objectMapper.createObjectNode(); + ArrayNode parts = objectMapper.createArrayNode(); + ObjectNode part = objectMapper.createObjectNode(); + part.put("text", prompt); + parts.add(part); + content.set("parts", parts); + contents.add(content); + root.set("contents", contents); + + String body = objectMapper.writeValueAsString(root); + + String url = + "https://generativelanguage.googleapis.com/v1beta/models/" + + model + + ":generateContent?key=" + + apiKey; 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(); - } - } + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(body, headers), + String.class + ); - private SubmissionResultDto parseResponse(String jsonResponse, int totalTestCases) { - try { - JsonNode root = objectMapper.readTree(jsonResponse); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.error("Gemini API 호출 실패: {}", response.getBody()); + return buildErrorResult("AI 채점 서버 응답이 올바르지 않습니다."); + } - String rawText = null; - JsonNode cand = root.path("candidates").get(0); + // 3) Gemini 응답 파싱 + JsonNode rootNode = objectMapper.readTree(response.getBody()); - if (cand.has("output_text")) { - rawText = cand.path("output_text").asText(); + // 최신 Gemini 응답구조: candidates → content → parts → text + JsonNode candidates = rootNode.path("candidates"); + if (!candidates.isArray() || candidates.size() == 0) { + log.error("Gemini 응답에 candidates 없음: {}", response.getBody()); + return buildErrorResult("AI 응답이 비어 있습니다."); } - 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(); - } + JsonNode contentNode = candidates.get(0).path("content"); + JsonNode partsNode = contentNode.path("parts"); + if (!partsNode.isArray() || partsNode.size() == 0) { + log.error("Gemini 응답에 parts 없음: {}", response.getBody()); + return buildErrorResult("AI 응답 파싱 실패."); } - if (rawText == null || rawText.isEmpty()) { - throw new RuntimeException("AI 응답 파싱 실패"); + String rawText = partsNode.get(0).path("text").asText(); + if (rawText == null || rawText.isBlank()) { + log.error("Gemini 응답 text 없음: {}", response.getBody()); + return buildErrorResult("AI 응답이 비어 있습니다."); } - rawText = rawText.replace("```json", "") - .replace("```", "") - .trim(); + // 🔥 4) text 안에서 JSON 부분만 추출 + String jsonString = extractJsonString(rawText); + if (jsonString == null) { + log.error("Gemini 응답에서 JSON 부분 추출 실패. rawText={}", rawText); + return buildErrorResult("AI 응답 JSON 파싱 실패"); + } - JsonNode resultNode = objectMapper.readTree(rawText); + // 5) JSON 파싱 + JsonNode json; + try { + json = objectMapper.readTree(jsonString); + } catch (Exception e) { + log.error("AI JSON 파싱 실패. jsonString={}", jsonString, e); + return buildErrorResult("AI 응답 JSON 파싱 실패"); + } - String status = resultNode.path("status").asText("WA"); - int score = resultNode.path("score").asInt(0); - String feedback = resultNode.path("feedback").asText("피드백 없음"); + // 6) AI 결과 해석 + String status = json.path("status").asText("WA"); // 기본값 WA + int score = json.path("score").asInt(0); + String feedback = json.path("feedback").asText(""); + + // 7) 면접 질문 파싱 + List interviewQuestions = new ArrayList<>(); + JsonNode qNode = json.path("interviewQuestions"); + if (qNode.isArray()) { + for (JsonNode q : qNode) { + if (q.isTextual()) interviewQuestions.add(q.asText()); + } + } - int passedCount = (score == 100) - ? totalTestCases - : (int) Math.round(totalTestCases * (score / 100.0)); + // 8) 테스트케이스 기반 점수 계산 (문제 데이터 기반) + Integer totalTestCases = problem.getTestCases() != null + ? problem.getTestCases().size() + : null; + Integer passedCount = null; + if (totalTestCases != null && totalTestCases > 0) { + passedCount = (int) Math.round(totalTestCases * (score / 100.0)); + } + // 9) 최종 반환 return SubmissionResultDto.builder() .status(status) .score(score) .passedCount(passedCount) .totalCount(totalTestCases) - .message(score == 100 ? "정답입니다! 🎉" : "오답입니다.") + .message(status.equals("AC") ? "정답입니다! 🎉" : "오답입니다.") .aiFeedback(feedback) + .interviewQuestions(interviewQuestions) .build(); } catch (Exception e) { - log.error("AI 응답 파싱 실패", e); - return SubmissionResultDto.builder() - .status("ERROR") - .score(0) - .message("채점 오류") - .aiFeedback("AI 응답 분석 실패") - .build(); + log.error("AI 채점 실패", e); + return buildErrorResult("AI 채점 중 오류 발생"); } } - private String createPrompt(CodingProblem problem, String userCode, String language) { + /** + * AI 실패 fallback + */ + private SubmissionResultDto buildErrorResult(String message) { + List fallbackQuestions = List.of( + "이 문제를 해결하기 위해 선택한 자료구조와 알고리즘을 설명해주세요.", + "시간 복잡도를 줄이기 위해 어떤 개선이 가능할까요?", + "극단적인 입력값이 들어왔을 때 어떤 문제가 발생할 수 있을까요?" + ); + + return SubmissionResultDto.builder() + .status("WA") // 실패 시 절대 AC로 보이지 않게 + .score(0) + .message(message) + .aiFeedback("AI 분석 실패: " + message) + .interviewQuestions(fallbackQuestions) + .build(); + } + + /** + * 프롬프트 생성 + */ + private String buildPrompt(CodingProblem problem, String language, String userCode) { return """ - You are a strict Algorithm Coding Test Judge. + 너는 코딩 테스트 문제를 채점하는 한국인 시니어 개발자이다. + + 아래 문제와 사용자의 코드를 보고 JSON만 출력해라. - [PROBLEM TITLE]: %s - [PROBLEM DESCRIPTION]: %s + 오직 아래 JSON 형식만, 앞뒤 설명 없이 출력해야 한다: - [USER CODE - %s]: - %s + { + "status": "AC" 또는 "WA", + "score": 0~100, + "feedback": "한국어 코드 리뷰", + "interviewQuestions": [ + "질문1", + "질문2", + "질문3" + ] + } - Return ONLY pure JSON (no extra text): + --- 문제 정보 --- + 제목: %s - { - "status": "AC" or "WA", - "score": 0~100, - "feedback": "한국어 피드백" - } - """.formatted( + 설명: + %s + + --- 사용 언어 --- + %s + + --- 사용자 코드 --- + %s + """.formatted( problem.getTitle(), problem.getDescription(), language, userCode ); } + + /** + * 모델이 쓸데없이 앞뒤에 텍스트를 붙일 때, + * 그 안에서 JSON 부분만 잘라내기 위한 유틸 함수. + */ + private String extractJsonString(String rawText) { + if (rawText == null) return null; + + String text = rawText.trim(); + + // ```json ... ``` 같은 코드블럭 제거 + if (text.startsWith("```")) { + int firstBrace = text.indexOf('{'); + int lastBrace = text.lastIndexOf('}'); + if (firstBrace != -1 && lastBrace != -1 && lastBrace > firstBrace) { + return text.substring(firstBrace, lastBrace + 1); + } + } + + // 일반 텍스트일 때도 첫 '{' ~ 마지막 '}' 사이만 추출 + int start = text.indexOf('{'); + int end = text.lastIndexOf('}'); + if (start == -1 || end == -1 || end <= start) { + return null; + } + + return text.substring(start, end + 1).trim(); + } } diff --git a/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java index d5390d8..36c882b 100644 --- a/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java +++ b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java @@ -1,8 +1,20 @@ package com.example.skillboost.codingtest.service; +import com.example.skillboost.codingtest.dto.SubmissionRequestDto; +import com.example.skillboost.codingtest.dto.SubmissionResultDto; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class CodingTestService { - // TODO: 현재 사용하지 않음. 나중에 제출 기록 저장 기능 추가할 때 구현. + + private final GradingService gradingService; + + /** + * 코딩 테스트 제출 처리 + */ + public SubmissionResultDto submitCode(SubmissionRequestDto request) { + return gradingService.grade(request); + } } diff --git a/src/main/java/com/example/skillboost/codingtest/service/GradingService.java b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java index 149ddfc..7cf61de 100644 --- a/src/main/java/com/example/skillboost/codingtest/service/GradingService.java +++ b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java @@ -6,24 +6,32 @@ 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; + private final GeminiJudge geminiJudge; + /** + * 실제 AI 기반 채점 + */ public SubmissionResultDto grade(SubmissionRequestDto request) { + // 1) 문제 조회 CodingProblem problem = problemRepository.findById(request.getProblemId()) .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); - // DB에 제출 저장 같은 건 나중에 하고, - // 일단 AI 채점만 연결 - return judge.grade(problem, request.getCode(), request.getLanguage()); + // 2) AI 채점 실행 + SubmissionResultDto aiResult = geminiJudge.grade( + problem, + request.getCode(), + request.getLanguage() + ); + + // 3) 결과 그대로 반환 (AI가 최종 판정) + return aiResult; } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35e1022..e4b0232 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: # + key: AIzaSyCN_4oQ7m6X8bJIEcd0Xy9S9Xw3DGUug_w model: gemini-2.5-flash From ca36ca5e82b71ce93b2d5e7379b9ffee348db39b Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 20:50:09 +0900 Subject: [PATCH 19/21] Complete coding test backend --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e4b0232..35e1022 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,5 +23,5 @@ server: gemini: api: - key: AIzaSyCN_4oQ7m6X8bJIEcd0Xy9S9Xw3DGUug_w + key: # model: gemini-2.5-flash From 2a715a5f82dc61574b302287da5538a9a4010695 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 23:41:01 +0900 Subject: [PATCH 20/21] AI Code Review backend complete --- .../controller/CodeReviewController.java | 1 + .../codereview/dto/CodeReviewRequest.java | 28 +++++ .../codereview/dto/CodeReviewResponse.java | 21 +--- .../codereview/github/GithubController.java | 4 + .../codereview/github/GithubFile.java | 18 +++ .../codereview/github/GithubService.java | 114 ++++++++++++++++++ .../llm/GeminiCodeReviewClient.java | 91 ++++++++++---- .../service/CodeReviewServiceImpl.java | 21 +++- src/main/resources/application.yml | 2 - 9 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubController.java create mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubFile.java create mode 100644 src/main/java/com/example/skillboost/codereview/github/GithubService.java diff --git a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java index d1dc99f..0c01fe6 100644 --- a/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java +++ b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java @@ -1,3 +1,4 @@ +// src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java package com.example.skillboost.codereview.controller; import com.example.skillboost.codereview.dto.CodeReviewRequest; diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java index 60eb4ac..e413c1d 100644 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java @@ -1,3 +1,4 @@ +// src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java package com.example.skillboost.codereview.dto; public class CodeReviewRequest { @@ -5,6 +6,10 @@ public class CodeReviewRequest { private String code; private String comment; + // 🔹 레포지터리 기반 리뷰용 필드 + private String repoUrl; // 예: https://github.com/Junseung-Ock/java-calculator-7 + private String branch; // 기본값: main + public CodeReviewRequest() { } @@ -13,6 +18,13 @@ public CodeReviewRequest(String code, String comment) { this.comment = comment; } + public CodeReviewRequest(String code, String comment, String repoUrl, String branch) { + this.code = code; + this.comment = comment; + this.repoUrl = repoUrl; + this.branch = branch; + } + public String getCode() { return code; } @@ -28,4 +40,20 @@ public String getComment() { public void setComment(String comment) { this.comment = comment; } + + public String getRepoUrl() { + return repoUrl; + } + + public void setRepoUrl(String repoUrl) { + this.repoUrl = repoUrl; + } + + public String getBranch() { + return branch; + } + + public void setBranch(String branch) { + this.branch = branch; + } } diff --git a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java index 5440b19..6fb48fd 100644 --- a/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java @@ -8,27 +8,16 @@ public class CodeReviewResponse { private String review; private List questions = new ArrayList<>(); - public CodeReviewResponse() { - } + 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 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; - } + public List getQuestions() { return questions; } + public void setQuestions(List questions) { this.questions = questions; } } diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubController.java b/src/main/java/com/example/skillboost/codereview/github/GithubController.java new file mode 100644 index 0000000..0ccb95e --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/github/GithubController.java @@ -0,0 +1,4 @@ +package com.example.skillboost.codereview.github; + +public class GithubController { +} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubFile.java b/src/main/java/com/example/skillboost/codereview/github/GithubFile.java new file mode 100644 index 0000000..fa2e5b7 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/github/GithubFile.java @@ -0,0 +1,18 @@ +// src/main/java/com/example/skillboost/codereview/github/GithubFile.java +package com.example.skillboost.codereview.github; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GithubFile { + + private String path; + private String content; + + public GithubFile(String path, String content) { + this.path = path; + this.content = content; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/github/GithubService.java b/src/main/java/com/example/skillboost/codereview/github/GithubService.java new file mode 100644 index 0000000..0b81c55 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/github/GithubService.java @@ -0,0 +1,114 @@ +// src/main/java/com/example/skillboost/codereview/github/GithubService.java +package com.example.skillboost.codereview.github; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +@Slf4j +@Service +public class GithubService { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${github.token:}") + private String githubToken; + + private static final List TEXT_EXTENSIONS = List.of( + ".java", ".kt", ".xml", ".json", ".yml", ".yaml", + ".md", ".gradle", ".gitignore", ".txt", ".properties", ".csv" + ); + + public List fetchRepoCode(String repoUrl, String branch) { + if (repoUrl == null || !repoUrl.contains("github.com/")) { + throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); + } + + try { + String[] parts = repoUrl.replace("https://github.com/", "") + .replace("http://github.com/", "") + .split("/"); + if (parts.length < 2) throw new IllegalArgumentException("잘못된 GitHub URL 형식입니다."); + + String owner = parts[0]; + String repo = parts[1]; + + String treeUrl = String.format( + "https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1", + owner, repo, branch + ); + + log.info("[GithubService] tree 호출: {}", treeUrl); + + HttpHeaders headers = new HttpHeaders(); + if (githubToken != null && !githubToken.isEmpty()) { + headers.setBearerAuth(githubToken); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity resp = restTemplate.exchange( + treeUrl, HttpMethod.GET, entity, Map.class + ); + + Map body = resp.getBody(); + if (body == null || !body.containsKey("tree")) { + return Collections.emptyList(); + } + + List> tree = (List>) body.get("tree"); + List files = new ArrayList<>(); + + for (Map file : tree) { + if (!"blob".equals(file.get("type"))) continue; + + String path = (String) file.get("path"); + if (!isTextFile(path)) continue; + + String rawUrl = String.format( + "https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, repo, branch, path + ); + + String content = fetchFileContent(rawUrl); + files.add(new GithubFile(path, content)); + } + + log.info("[GithubService] {} 개 파일 로드 완료", files.size()); + return files; + + } catch (Exception e) { + log.error("[GithubService] 레포지터리 로드 실패: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + private String fetchFileContent(String rawUrl) { + try { + HttpHeaders headers = new HttpHeaders(); + if (githubToken != null && !githubToken.isEmpty()) { + headers.setBearerAuth(githubToken); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity resp = restTemplate.exchange( + rawUrl, HttpMethod.GET, entity, String.class + ); + return resp.getBody() != null ? resp.getBody() : ""; + } catch (Exception e) { + log.warn("[GithubService] 파일 읽기 실패: {} ({})", rawUrl, e.getMessage()); + return ""; + } + } + + private boolean isTextFile(String path) { + String lower = path.toLowerCase(); + for (String ext : TEXT_EXTENSIONS) { + if (lower.endsWith(ext)) return true; + } + return false; + } +} diff --git a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java index 90b8680..a3e8145 100644 --- a/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java +++ b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java @@ -1,6 +1,8 @@ -package com.example.skillboost.codereview.client; +// src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java +package com.example.skillboost.codereview.llm; import com.example.skillboost.codereview.dto.CodeReviewResponse; +import com.example.skillboost.codereview.github.GithubFile; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -28,12 +30,18 @@ public GeminiCodeReviewClient( this.objectMapper = new ObjectMapper(); } + // 🔹 코드만 사용하는 기존 모드 (호환용) public CodeReviewResponse requestReview(String code, String comment) { + return requestReview(code, comment, null); + } + + // 🔹 레포지터리 컨텍스트까지 함께 넘기는 확장 버전 + public CodeReviewResponse requestReview(String code, String comment, List repoContext) { try { String url = "https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent?key=" + apiKey; - String prompt = buildPrompt(code, comment); + String prompt = buildPrompt(code, comment, repoContext); Map textPart = new HashMap<>(); textPart.put("text", prompt); @@ -70,14 +78,66 @@ public CodeReviewResponse requestReview(String code, String comment) { } /** - * 리뷰 + 질문을 "간결하고 핵심적"이고 "□ 포맷", "1. 2. 질문 구조"로 내보내도록 만드는 프롬프트 + * 코드 + (선택) GitHub 레포지터리 컨텍스트(README, 파일구조, 일부 코드)를 포함한 프롬프트 */ - private String buildPrompt(String code, String comment) { + private String buildPrompt(String code, String comment, List repoContext) { String userRequirement = (comment != null && !comment.trim().isEmpty()) ? comment.trim() : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; - return """ + StringBuilder sb = new StringBuilder(); + + // 1) 레포지터리 전체 맥락 + if (repoContext != null && !repoContext.isEmpty()) { + sb.append("이 코드는 GitHub 레포지터리 전체 맥락 안에 있는 일부 코드입니다.\n") + .append("레포지터리의 README와 파일 구조, 주요 코드 파일을 참고해서 '요구사항을 만족하는지'와 '아키텍처 적절성'까지 함께 리뷰해 주세요.\n\n"); + + // README 찾기 + GithubFile readme = repoContext.stream() + .filter(f -> f.getPath().equalsIgnoreCase("README.md") + || f.getPath().toLowerCase().endsWith("/readme.md")) + .findFirst() + .orElse(null); + + if (readme != null && readme.getContent() != null) { + String readmeContent = readme.getContent(); + if (readmeContent.length() > 2000) { + readmeContent = readmeContent.substring(0, 2000) + "\n... (생략)"; + } + + sb.append("=== README (요구사항 기준) ===\n"); + sb.append(readmeContent).append("\n\n"); + } + + // 파일 목록 (최대 40개) + sb.append("=== 프로젝트 파일 구조 (일부) ===\n"); + repoContext.stream() + .limit(40) + .forEach(f -> sb.append("- ").append(f.getPath()).append("\n")); + if (repoContext.size() > 40) { + sb.append("... 외 ").append(repoContext.size() - 40).append("개 파일 더 있음\n"); + } + sb.append("\n"); + + // 주요 코드 샘플 (java 위주 최대 5개) + sb.append("=== 주요 코드 샘플 (일부) ===\n"); + repoContext.stream() + .filter(f -> f.getPath().endsWith(".java")) + .limit(5) + .forEach(f -> { + sb.append("#### ").append(f.getPath()).append("\n"); + String c = f.getContent(); + if (c != null && c.length() > 1200) { + c = c.substring(0, 1200) + "\n... (생략)"; + } + sb.append(c == null ? "" : c).append("\n\n"); + }); + + sb.append("위 정보를 참고하여, 아래 사용자가 제공한 코드가 이 레포지터리/README 요구사항과 잘 맞는지 검토해 주세요.\n\n"); + } + + // 2) 여기부터는 JSON 형식 / 출력 규칙 안내 (기존 로직 유지) + sb.append(""" 너는 숙련된 시니어 백엔드 개발자이자 코드 리뷰어야. 아래 코드를 분석해서 반드시 **JSON 형식 하나만** 출력해. @@ -107,16 +167,14 @@ private String buildPrompt(String code, String comment) { } 사용자가 요청한 요구사항: - %s + """).append("\n") + .append(userRequirement).append("\n\n") + .append("리뷰할 코드:\n") + .append(code); - 리뷰할 코드: - %s - """.formatted(userRequirement, code); + return sb.toString(); } - /** - * Gemini 응답(JSON 스트링)을 CodeReviewResponse로 변환 - */ private CodeReviewResponse parseGeminiResponse(String body) throws Exception { JsonNode root = objectMapper.readTree(body); @@ -142,10 +200,8 @@ private CodeReviewResponse parseGeminiResponse(String body) throws Exception { return resp; } - // ```json ... ``` 형태 제거 String cleaned = stripCodeFence(rawText); - // JSON 파싱 try { JsonNode json = objectMapper.readTree(cleaned); @@ -164,7 +220,6 @@ private CodeReviewResponse parseGeminiResponse(String body) throws Exception { return resp; } catch (Exception e) { - // JSON 파싱 실패 시 그대로 리뷰로 전달 CodeReviewResponse resp = new CodeReviewResponse(); resp.setReview(cleaned); resp.setQuestions(Collections.emptyList()); @@ -172,12 +227,6 @@ private CodeReviewResponse parseGeminiResponse(String body) throws Exception { } } - /** - * ```json - * {...} - * ``` - * 같은 코드블럭 제거 - */ private String stripCodeFence(String text) { if (text == null) return ""; String trimmed = text.trim(); diff --git a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java index 6635aed..1edc828 100644 --- a/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java @@ -1,17 +1,24 @@ +// src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java 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 com.example.skillboost.codereview.github.GithubFile; +import com.example.skillboost.codereview.github.GithubService; +import com.example.skillboost.codereview.llm.GeminiCodeReviewClient; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import java.util.Collections; +import java.util.List; + @Service @RequiredArgsConstructor public class CodeReviewServiceImpl implements CodeReviewService { private final GeminiCodeReviewClient geminiCodeReviewClient; + private final GithubService githubService; @Override public CodeReviewResponse review(CodeReviewRequest request) { @@ -21,7 +28,17 @@ public CodeReviewResponse review(CodeReviewRequest request) { String code = request.getCode(); String comment = request.getComment(); + String repoUrl = request.getRepoUrl(); + String branch = StringUtils.hasText(request.getBranch()) ? request.getBranch() : "main"; + + List repoContext = Collections.emptyList(); + + // 🔹 repoUrl 이 있으면 GitHub 레포 전체 읽어오기 + if (StringUtils.hasText(repoUrl)) { + repoContext = githubService.fetchRepoCode(repoUrl, branch); + } - return geminiCodeReviewClient.requestReview(code, comment); + // 🔹 코드 + (있다면) 레포 컨텍스트 기반으로 Gemini에 리뷰 요청 + return geminiCodeReviewClient.requestReview(code, comment, repoContext); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35e1022..9dcd55c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,4 @@ server: port: 8080 gemini: - api: - key: # model: gemini-2.5-flash From 983296a0e1fc92f72ff3d691dcbbcd01c034c303 Mon Sep 17 00:00:00 2001 From: ChoiWonkeun Date: Fri, 28 Nov 2025 23:54:41 +0900 Subject: [PATCH 21/21] AI Code Review backend complete --- src/main/resources/application.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9dcd55c..35e1022 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,4 +22,6 @@ server: port: 8080 gemini: + api: + key: # model: gemini-2.5-flash