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 bcf0702..8484c3d 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,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') { 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/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..e0d1432 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -0,0 +1,129 @@ +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 accessTokenExpirationMs; + + private final long refreshTokenExpirationMs = 14 * 24 * 60 * 60 * 1000L; + + @PostConstruct + protected void init() { + 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; + } + } + /** + * 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, long expirationTime) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + /** + * 토큰에서 사용자 ID(Email) 추출 + */ + public String getUserId(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + /** + * 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) { + String email = getUserId(token); + + User principal = new User( + email, + "", + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")) + ); + + return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + } +} \ No newline at end of file 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..f5eff35 --- /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 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..3b01d86 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/controller/AuthController.java @@ -0,0 +1,48 @@ +package com.example.skillboost.auth.controller; + +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 = "로그인, 토큰 재발급, 로그아웃") +@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() { + 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 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 new file mode 100644 index 0000000..0aeac80 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -0,0 +1,78 @@ +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; +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 TokenService tokenService; + 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"); + + if (email == null || email.isEmpty()) { + String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); + email = githubId + "@github.temp"; + log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); + } + + final String finalEmail = email; + + User user = userRepository.findByEmail(finalEmail) + .orElseThrow(() -> { + log.error("사용자를 찾을 수 없습니다: {}", finalEmail); + return new RuntimeException("User not found: " + finalEmail); + }); + + 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("accessToken", accessToken); + responseData.put("refreshToken", refreshToken); // 프론트엔드에서 저장해야 함 + 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?accessToken=" + accessToken + "&refreshToken=" + refreshToken); + } +} \ 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 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/codereview/controller/CodeReviewController.java b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java new file mode 100644 index 0000000..0c01fe6 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java @@ -0,0 +1,26 @@ +// src/main/java/com/example/skillboost/codereview/controller/CodeReviewController.java +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..e413c1d --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java @@ -0,0 +1,59 @@ +// src/main/java/com/example/skillboost/codereview/dto/CodeReviewRequest.java +package com.example.skillboost.codereview.dto; + +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() { + } + + public CodeReviewRequest(String code, String comment) { + this.code = code; + 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; + } + + public void setCode(String code) { + this.code = code; + } + + public String getComment() { + return comment; + } + + 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 new file mode 100644 index 0000000..6fb48fd --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/dto/CodeReviewResponse.java @@ -0,0 +1,23 @@ +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/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 new file mode 100644 index 0000000..a3e8145 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/llm/GeminiCodeReviewClient.java @@ -0,0 +1,245 @@ +// 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; +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) { + 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, repoContext); + + 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; + } + } + + /** + * 코드 + (선택) GitHub 레포지터리 컨텍스트(README, 파일구조, 일부 코드)를 포함한 프롬프트 + */ + private String buildPrompt(String code, String comment, List repoContext) { + String userRequirement = (comment != null && !comment.trim().isEmpty()) + ? comment.trim() + : "특별한 추가 요구사항은 없습니다. 핵심만 간결하게 리뷰해줘."; + + 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 형식 하나만** 출력해. + + ⚠️ 모든 출력은 반드시 한국어로 작성해. + 마크다운 금지(**, ```, # 등) + JSON 외 텍스트 출력 금지. + + 🔒 출력 형식 규칙 + - review 항목은: + - 모든 줄을 '□ ' 로 시작 + - 한 줄은 핵심 한 문장 + - 항목 사이에는 빈 줄(\\n\\n) 있어야 함 + + - questions 항목은: + - 배열 형태 + - 각 질문은 한국어 한 문장 + - 번호(1. 2.)는 넣지 말 것 + + JSON 예시: + + { + "review": "□ 핵심 피드백입니다.\\n\\n□ 또 다른 핵심 피드백입니다.", + "questions": [ + "이 코드에서 개선할 수 있는 부분은 무엇인가요?", + "예외 처리를 추가한다면 어떤 케이스를 고려하겠습니까?" + ] + } + + 사용자가 요청한 요구사항: + """).append("\n") + .append(userRequirement).append("\n\n") + .append("리뷰할 코드:\n") + .append(code); + + return sb.toString(); + } + + 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; + } + + String cleaned = stripCodeFence(rawText); + + 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) { + CodeReviewResponse resp = new CodeReviewResponse(); + resp.setReview(cleaned); + resp.setQuestions(Collections.emptyList()); + return resp; + } + } + + 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..1edc828 --- /dev/null +++ b/src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java @@ -0,0 +1,44 @@ +// src/main/java/com/example/skillboost/codereview/service/CodeReviewServiceImpl.java +package com.example.skillboost.codereview.service; + +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) { + if (request == null || !StringUtils.hasText(request.getCode())) { + throw new IllegalArgumentException("코드가 비어 있습니다."); + } + + 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); + } + + // 🔹 코드 + (있다면) 레포 컨텍스트 기반으로 Gemini에 리뷰 요청 + return geminiCodeReviewClient.requestReview(code, comment, repoContext); + } +} 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..d16b355 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/controller/SubmissionController.java @@ -0,0 +1,42 @@ +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; + + /** + * 코딩 테스트 제출 + 채점 + * POST /api/coding/submissions + */ + @PostMapping("/submissions") + public ResponseEntity submit(@RequestBody SubmissionRequestDto request) { + log.info("코딩테스트 제출 요청: problemId={}, language={}, userId={}", + request.getProblemId(), request.getLanguage(), request.getUserId()); + + if (request.getCode() == null || request.getCode().isBlank()) { + 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..7a07fef --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/domain/CodingSubmission.java @@ -0,0 +1,70 @@ +package com.example.skillboost.codingtest.domain; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "coding_submission") +public class CodingSubmission { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 문제 ID + @Column(nullable = false) + private Long problemId; + + // 유저 ID + @Column(nullable = false) + private Long userId; + + // 사용 언어 (python / java / cpp ...) + @Column(length = 20) + private String language; + + // 제출 코드 + @Lob + @Column(nullable = false) + private String sourceCode; + + // "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() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} 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..aa43aaa --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionRequestDto.java @@ -0,0 +1,26 @@ +package com.example.skillboost.codingtest.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 프론트에서 /api/coding/submissions 로 보내는 요청 DTO + */ +@Data +@NoArgsConstructor +public class SubmissionRequestDto { + + // 문제 ID + private Long problemId; + + // 프론트 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 new file mode 100644 index 0000000..119af77 --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/dto/SubmissionResultDto.java @@ -0,0 +1,38 @@ +package com.example.skillboost.codingtest.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionResultDto { + + private Long submissionId; + + // "AC"(정답), "WA"(오답) 등 + private String status; + + // 0 ~ 100점 + private Integer score; + + // 통과한 테스트케이스 수 (없으면 null 가능) + private Integer passedCount; + + // 전체 테스트케이스 수 (없으면 null 가능) + private Integer totalCount; + + // "정답입니다! 🎉" 같은 간단 메시지 + private String message; + + // 🔹 AI 코드 리뷰 텍스트 + private String aiFeedback; + + // 🔥 예상 면접 질문 리스트 (프론트에서 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 new file mode 100644 index 0000000..255149b --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/init/CodingTestDataInitializer.java @@ -0,0 +1,1189 @@ +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 문제들 + // ========================= + createExamSupervisorProblem(); // 시험 감독 + createZoacDistancingProblem(); // ZOAC 거리두기 + createDjmaxRankingProblem(); // DJMAX 랭킹 + createMinHeapProblem(); // 최소 힙 + createTriangleProblem(); // 삼각형 분류 + createMakeOneProblem(); // 1로 만들기 + createNumberCardProblem(); // 숫자 카드 + + // ========================= + // MEDIUM 문제들 + // ========================= + createSnakeGameProblem(); // Dummy (뱀 게임) + createDiceSimulationProblem(); // 주사위 굴리기 + createTargetDistanceProblem(); // 목표지점 거리 + createDfsBfsProblem(); // DFS와 BFS + createTripPlanningProblem(); // 여행 가자 + createChristmasGiftProblem(); // 크리스마스 선물 + createCardBuyingProblem(); // 카드 구매하기 + createFireEscapeProblem(); // 불! + + // ========================= + // HARD 문제들 + // ========================= + createMarbleEscapeProblem(); // 구슬 탈출 + createSharkCopyMagicProblem(); // 마법사 상어와 복제 + createSimilarWordsProblem(); // 비슷한 단어 + createJewelThiefProblem(); // 보석 도둑 + createMarsExplorationProblem(); // 화성 탐사 + createLectureTourProblem(); // 순회강연 + createLectureRoomAssignmentProblem(); // 강의실 배정 + createPopulationMovementProblem(); // 인구 이동 + createPrisonBreakProblem(); // 탈옥 + } + + // ========================= + // 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); + } + + // 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 문제들 + // ========================= + + // 8. 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); + } + + // 9. 주사위 굴리기 + 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); + } + + // 10. 목표지점 거리 + 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); + } + + // 11. 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); + } + + // 12. 여행 가자 + private void createTripPlanningProblem() { + if (problemRepository.existsByTitle("여행 가자")) { + return; + } + + String description = """ + [문제] + + 동혁이는 친구들과 함께 여행을 가려고 한다. + 한국에는 도시가 N개 있고 임의의 두 도시 사이에 길이 있을 수도, 없을 수도 있다. + + 동혁이의 여행 일정이 주어졌을 때, 이 여행 경로가 가능한 것인지 알아보자. + 물론 중간에 다른 도시를 경유해서 여행을 할 수도 있다. + + 도시들의 개수와 도시들 간의 연결 여부가 주어져 있고, + 동혁이의 여행 계획에 속한 도시들이 순서대로 주어졌을 때 가능한지 여부를 판별하는 프로그램을 작성하시오. + 같은 도시를 여러 번 방문하는 것도 가능하다. + + + [입력] + + 첫 줄에 도시의 수 N이 주어진다. (N ≤ 200) + 둘째 줄에 여행 계획에 속한 도시들의 수 M이 주어진다. (M ≤ 1000) + + 다음 N개의 줄에는 N개의 정수가 주어진다. + i번째 줄의 j번째 수는 i번 도시와 j번 도시의 연결 정보를 의미한다. + 1이면 연결된 것이고 0이면 연결이 되지 않은 것이다. + + 마지막 줄에는 여행 계획이 주어진다. + 도시의 번호는 1부터 N까지 차례대로 매겨져 있다. + + + [출력] + + 첫 줄에 가능하면 YES, 불가능하면 NO를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("여행 가자") + .difficulty(Difficulty.MEDIUM) + .description(description) + .tags("graph,union_find,bfs") + .build(); + + 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 문제들 + // ========================= + + // 16. 구슬 탈출 (이미 기존에 있던 문제) + 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); + } + + // 17. 마법사 상어와 복제 + private void createSharkCopyMagicProblem() { + if (problemRepository.existsByTitle("마법사 상어와 복제")) { + return; + } + + String description = """ + [문제] + + 마법사 상어는 물복사버그 마법의 상위 마법인 복제를 배웠고, + 4 × 4 크기의 격자에서 연습하려고 한다. + + 격자에는 물고기 M마리가 있다. + 각 물고기는 격자의 칸 하나에 들어가 있으며, 이동 방향을 가지고 있다. + 이동 방향은 8가지 방향(상하좌우, 대각선) 중 하나이다. + 마법사 상어도 연습을 위해 격자에 들어가 있다. + + 상어의 마법 연습 한 번은 다음과 같은 작업이 순차적으로 이루어진다. + + 1. 상어가 모든 물고기에게 복제 마법을 시전한다. + 2. 모든 물고기가 한 칸 이동한다. + 3. 상어가 연속해서 3칸 이동하면서 물고기를 먹고 냄새를 남긴다. + 4. 두 번 전 연습에서 생긴 물고기의 냄새가 격자에서 사라진다. + 5. 1에서 사용된 복제 마법이 완료되어 복제된 물고기가 생성된다. + + + [입력] + + 첫째 줄에 물고기의 수 M, 연습 횟수 S가 주어진다. + 다음 M개의 줄에는 물고기의 정보 (fx, fy, d)가 주어진다. + 마지막 줄에는 상어의 위치 (sx, sy)가 주어진다. + + + [출력] + + S번의 연습을 마친 후 격자에 있는 물고기의 수를 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("마법사 상어와 복제") + .difficulty(Difficulty.HARD) + .description(description) + .tags("simulation,backtracking,implementation") + .build(); + + problemRepository.save(problem); + } + + // 18. 비슷한 단어 + private void createSimilarWordsProblem() { + if (problemRepository.existsByTitle("비슷한 단어")) { + return; + } + + String description = """ + [문제] + + N개의 영단어들이 주어졌을 때, 가장 비슷한 두 단어를 구해내는 프로그램을 작성하시오. + + 두 단어의 비슷한 정도는 두 단어의 접두사의 길이로 측정한다. + 접두사란 두 단어의 앞부분에서 공통적으로 나타나는 부분문자열을 말한다. + 즉, 두 단어의 앞에서부터 M개의 글자들이 같으면서 M이 최대인 경우를 구하는 것이다. + + + [입력] + + 첫째 줄에 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); + } + + // 19. 보석 도둑 + private void createJewelThiefProblem() { + if (problemRepository.existsByTitle("보석 도둑")) { + return; + } + + String description = """ + [문제] + + 세계적인 도둑 상덕이는 보석점을 털기로 결심했다. + 상덕이가 털 보석점에는 보석이 총 N개 있다. + 각 보석은 무게 Mi와 가격 Vi를 가지고 있다. + + 상덕이는 가방을 K개 가지고 있고, + 각 가방에 담을 수 있는 최대 무게는 Ci이다. + 가방에는 최대 한 개의 보석만 넣을 수 있다. + + 상덕이가 훔칠 수 있는 보석의 최대 가격 합을 구하는 프로그램을 작성하시오. + + + [입력] + + 첫째 줄에 N과 K가 주어진다. (1 ≤ N, K ≤ 300,000) + 다음 N개 줄에는 각 보석의 정보 Mi와 Vi가 주어진다. + 다음 K개 줄에는 가방에 담을 수 있는 최대 무게 Ci가 주어진다. + + + [출력] + + 상덕이가 훔칠 수 있는 보석 가격의 합의 최댓값을 출력한다. + """; + + CodingProblem problem = CodingProblem.builder() + .title("보석 도둑") + .difficulty(Difficulty.HARD) + .description(description) + .tags("greedy,sorting,priority_queue") + .build(); + + problemRepository.save(problem); + } + + // 20. 화성 탐사 + 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); + } + + // 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 new file mode 100644 index 0000000..ecb286b --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/judge/GeminiJudge.java @@ -0,0 +1,249 @@ +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 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; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; + +/** + * 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:gemini-2.0-flash}") + private String model; + + /** + * AI 채점 메인 로직 + */ + public SubmissionResultDto grade(CodingProblem problem, String userCode, String language) { + try { + // 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); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(body, headers), + String.class + ); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + log.error("Gemini API 호출 실패: {}", response.getBody()); + return buildErrorResult("AI 채점 서버 응답이 올바르지 않습니다."); + } + + // 3) Gemini 응답 파싱 + JsonNode rootNode = objectMapper.readTree(response.getBody()); + + // 최신 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 응답이 비어 있습니다."); + } + + 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 응답 파싱 실패."); + } + + String rawText = partsNode.get(0).path("text").asText(); + if (rawText == null || rawText.isBlank()) { + log.error("Gemini 응답 text 없음: {}", response.getBody()); + return buildErrorResult("AI 응답이 비어 있습니다."); + } + + // 🔥 4) text 안에서 JSON 부분만 추출 + String jsonString = extractJsonString(rawText); + if (jsonString == null) { + log.error("Gemini 응답에서 JSON 부분 추출 실패. rawText={}", rawText); + return buildErrorResult("AI 응답 JSON 파싱 실패"); + } + + // 5) JSON 파싱 + JsonNode json; + try { + json = objectMapper.readTree(jsonString); + } catch (Exception e) { + log.error("AI JSON 파싱 실패. jsonString={}", jsonString, e); + return buildErrorResult("AI 응답 JSON 파싱 실패"); + } + + // 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()); + } + } + + // 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(status.equals("AC") ? "정답입니다! 🎉" : "오답입니다.") + .aiFeedback(feedback) + .interviewQuestions(interviewQuestions) + .build(); + + } catch (Exception e) { + log.error("AI 채점 실패", e); + return buildErrorResult("AI 채점 중 오류 발생"); + } + } + + /** + * 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 """ + 너는 코딩 테스트 문제를 채점하는 한국인 시니어 개발자이다. + + 아래 문제와 사용자의 코드를 보고 JSON만 출력해라. + + 오직 아래 JSON 형식만, 앞뒤 설명 없이 출력해야 한다: + + { + "status": "AC" 또는 "WA", + "score": 0~100, + "feedback": "한국어 코드 리뷰", + "interviewQuestions": [ + "질문1", + "질문2", + "질문3" + ] + } + + --- 문제 정보 --- + 제목: %s + + 설명: + %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/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..36c882b --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/CodingTestService.java @@ -0,0 +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 { + + 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 new file mode 100644 index 0000000..7cf61de --- /dev/null +++ b/src/main/java/com/example/skillboost/codingtest/service/GradingService.java @@ -0,0 +1,37 @@ +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 org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GradingService { + + private final CodingProblemRepository problemRepository; + private final GeminiJudge geminiJudge; + + /** + * 실제 AI 기반 채점 + */ + public SubmissionResultDto grade(SubmissionRequestDto request) { + + // 1) 문제 조회 + CodingProblem problem = problemRepository.findById(request.getProblemId()) + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); + + // 2) AI 채점 실행 + SubmissionResultDto aiResult = geminiJudge.grade( + problem, + request.getCode(), + request.getLanguage() + ); + + // 3) 결과 그대로 반환 (AI가 최종 판정) + return aiResult; + } +} 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/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/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); +} 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 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-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 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c22d980..35e1022 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: # + model: gemini-2.5-flash