diff --git "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" index 83a65f3..8a32412 100644 --- "a/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" +++ "b/.github/ISSUE_TEMPLATE/\354\235\264\354\212\210-\355\205\234\355\224\214\353\246\277.md" @@ -7,12 +7,12 @@ assignees: '' --- -##๐Ÿ’ก๊ฐœ๋ฐœํ•  ๊ธฐ๋Šฅ ์„ค๋ช… +## ๐Ÿ’ก๊ฐœ๋ฐœํ•  ๊ธฐ๋Šฅ ์„ค๋ช… -##๐Ÿ“ ์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ -- [] -- [] -- [] +## ๐Ÿ“์ž‘์—… ์ƒ์„ธ ๋‚ด์šฉ +- [ ] +- [ ] +- [ ] ## ์ฐธ๊ณ  ์ž๋ฃŒ(์„ ํƒ) diff --git a/.gitignore b/.gitignore index bf7518a..aad2a19 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,8 @@ Icon # Thumbnails ._* +node_modules + # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd @@ -168,4 +170,7 @@ gradle-app.setting # Java heap dump *.hprof -# End of https://www.toptal.com/developers/gitignore/api/windows,git,java,gradle,macos \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/windows,git,java,gradle,macos + +# QueryDSL ๊ฐ์ฒด +/src/main/generated/ diff --git a/README.md b/README.md index a4f39bd..9770d60 100644 --- a/README.md +++ b/README.md @@ -1 +1,40 @@ -# StudyLog-BE \ No newline at end of file +# StudyLog-BE + +### Git Flow ํ˜‘์—… ๋ฐฉ์‹ +``` +1. ์›๊ฒฉ ์ €์žฅ์†Œ๋ฅผ ๋กœ์ปฌ ์ปดํ“จํ„ฐ์— origin์œผ๋กœ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. +2. Origin Repository๋ฅผ ๋กœ์ปฌ ์ปดํ“จํ„ฐ๋กœ clone ๋ฐ pull์„ ํ•ฉ๋‹ˆ๋‹ค. +3. ๊ฐ์ž์˜ ์ด์Šˆ ๋ธŒ๋žœ์น˜์—์„œ ์ž‘์—… ํ›„ Origin Repository๋กœ push ํ•ฉ๋‹ˆ๋‹ค. +4. Develop ๋ธŒ๋žœ์น˜๋กœ PR์„ ๋ณด๋‚ธ ํ›„ merge ํ•ฉ๋‹ˆ๋‹ค. +``` +### ๊ฐœ๋ฐœ ์‹œ์ž‘ ์‹œ +``` +1. ์ƒˆ๋กœ์šด Issue๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. +2. develop ๋ธŒ๋žœ์น˜์—์„œ ์ด์Šˆ์— ๋Œ€ํ•œ ๋ธŒ๋žœ์น˜๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + - (๋ธŒ๋žœ์น˜ ์ด๋ฆ„: [์ž‘์—… ์œ ํ˜•]/#[์ด์Šˆ๋ฒˆํ˜ธ]-[์ž‘์—…ํ•  ๊ธฐ๋Šฅ]) + - ex) Feature/#1-kakao-login +3. ๋กœ์ปฌ์—์„œ Fetch๋ฅผ ํ•˜์—ฌ ์ƒ์„ฑ๋œ ๋ธŒ๋žœ์น˜๋ฅผ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. +4. ํ•ด๋‹น ๋ธŒ๋žœ์น˜๋กœ ์ด๋™(checkout)ํ•œ ํ›„ ์ž‘์—…ํ•ฉ๋‹ˆ๋‹ค. +``` +#### ์ด์Šˆ ์ƒ์„ฑ ์‹œ ์ฃผ์˜์‚ฌํ•ญ +``` +1. ์ด์Šˆ์˜ ์ œ๋ชฉ์€ "[์ž‘์—… ์œ ํ˜•] ์ž‘์—…ํ•  ๊ธฐ๋Šฅ"์œผ๋กœ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. + ex) [Feature] ํšŒ์›๊ฐ€์ž… +2. ๊ฐœ๋ฐœํ•  ๊ธฐ๋Šฅ๊ณผ ์ž‘์—…์˜ ์ƒ์„ธ ๋‚ด์šฉ์„ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค. +3. Assignee์™€ Label์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. +``` +### ๊ฐœ๋ฐœ ์™„๋ฃŒ ํ›„ +``` +1. ๊ฐœ๋ฐœ์„ ์™„๋ฃŒํ•˜๋ฉด Origin Repository๋กœ pushํ•ฉ๋‹ˆ๋‹ค. +2. ํ•ด๋‹น ๋ธŒ๋žœ์น˜์—์„œ develop ๋ธŒ๋žœ์น˜๋กœ ๋ฆฌ๋ทฐ์–ด๋ฅผ ์„ค์ •ํ•˜์—ฌ PR์„ ๋ณด๋ƒ…๋‹ˆ๋‹ค. +3. ์ฝ”๋“œ ๋ฆฌ๋ทฐ ํ›„, ๋ฆฌ๋ทฐ์–ด๊ฐ€ merge ํ•ฉ๋‹ˆ๋‹ค. +4. merge๊ฐ€ ์™„๋ฃŒ๋˜๋ฉด ๋กœ์ปฌ์—์„œ develop ๋ธŒ๋žœ์น˜๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค. +5. ์›๊ฒฉ ์ €์žฅ์†Œ์˜ develop ๋ธŒ๋žœ์น˜๋ฅผ ๋กœ์ปฌ์—์„œ pull ํ•ฉ๋‹ˆ๋‹ค. +``` +#### PR ๋ฉ”์‹œ์ง€ ์ œ๋ชฉ ํ˜•์‹ +``` +[Feature/#(issue-number)] ์ƒˆ๋กœ์šด ๊ธฐ๋Šฅ ์ถ”๊ฐ€ +[Fix/#(issue-number)] ๋ฒ„๊ทธ ์ˆ˜์ • +[Refactor/#(issue-number)] ์ฝ”๋“œ ๋ฆฌํŒฉํ† ๋ง +[Test/#(issue-number)] ํ…Œ์ŠคํŠธ ๋กœ์ง +``` diff --git a/build.gradle b/build.gradle index 61037e8..84f9123 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -32,6 +34,22 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + // spring-cloud-starter-aws + implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1') + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // test + testImplementation 'org.springframework.security:spring-security-test' + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + //QueryDsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" } tasks.named('test') { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b247914 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "study-log", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "claude": "^0.1.1" + } + }, + "node_modules/claude": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/claude/-/claude-0.1.1.tgz", + "integrity": "sha512-j7oSibqQdIODNhkI1sEJzHMiPsF43L/GqNbcA+eDDyGM10+x2sH9NW/PK6vM3z0J2tLDKMBcc5ZjVaoRinhuCA==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1353701 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "claude": "^0.1.1" + } +} diff --git a/src/main/java/org/example/studylog/StudyLogApplication.java b/src/main/java/org/example/studylog/StudyLogApplication.java index a7ec1ef..60f1636 100644 --- a/src/main/java/org/example/studylog/StudyLogApplication.java +++ b/src/main/java/org/example/studylog/StudyLogApplication.java @@ -2,7 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync +@EnableJpaAuditing @SpringBootApplication public class StudyLogApplication { diff --git a/src/main/java/org/example/studylog/client/ChatGptClient.java b/src/main/java/org/example/studylog/client/ChatGptClient.java new file mode 100644 index 0000000..87e29c2 --- /dev/null +++ b/src/main/java/org/example/studylog/client/ChatGptClient.java @@ -0,0 +1,14 @@ +package org.example.studylog.client; + +import org.example.studylog.dto.quiz.chatGPT.ChatGptRequest; +import org.example.studylog.dto.quiz.chatGPT.ChatGptResponse; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange("/chat/completions") +public interface ChatGptClient { + + @PostExchange + ChatGptResponse getChatCompletions(@RequestBody ChatGptRequest request); +} diff --git a/src/main/java/org/example/studylog/config/.gitkeep b/src/main/java/org/example/studylog/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/config/CorsMvcConfig.java b/src/main/java/org/example/studylog/config/CorsMvcConfig.java new file mode 100644 index 0000000..9c974a5 --- /dev/null +++ b/src/main/java/org/example/studylog/config/CorsMvcConfig.java @@ -0,0 +1,17 @@ +package org.example.studylog.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry corsRegistry) { + corsRegistry.addMapping("/**") + .exposedHeaders("Set-Cookie") + .allowedOrigins("http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"); // PATCH & OPTIONS ํฌํ•จ; + } +} diff --git a/src/main/java/org/example/studylog/config/HttpClientConfig.java b/src/main/java/org/example/studylog/config/HttpClientConfig.java new file mode 100644 index 0000000..06536fe --- /dev/null +++ b/src/main/java/org/example/studylog/config/HttpClientConfig.java @@ -0,0 +1,30 @@ +package org.example.studylog.config; + +import org.example.studylog.client.ChatGptClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class HttpClientConfig { + + @Value("${spring.openai.api-key}") + private String apiKey; + + @Bean + public ChatGptClient chatGptClient(){ + RestClient restClient = RestClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + + return factory.createClient(ChatGptClient.class); + } +} diff --git a/src/main/java/org/example/studylog/config/QueryDslConfig.java b/src/main/java/org/example/studylog/config/QueryDslConfig.java new file mode 100644 index 0000000..2512127 --- /dev/null +++ b/src/main/java/org/example/studylog/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package org.example.studylog.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory(){ + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/org/example/studylog/config/SecurityConfig.java b/src/main/java/org/example/studylog/config/SecurityConfig.java new file mode 100644 index 0000000..1bd56dc --- /dev/null +++ b/src/main/java/org/example/studylog/config/SecurityConfig.java @@ -0,0 +1,121 @@ +package org.example.studylog.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.example.studylog.jwt.JWTFilter; +import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.oauth2.CustomFailureHandler; +import org.example.studylog.oauth2.CustomSuccessHandler; +import org.example.studylog.oauth2.ProfileCheckFilter; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.oauth.CustomOAuth2UserService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; + + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomSuccessHandler customSuccessHandler; + private final CustomFailureHandler customFailureHandler; + private final JWTUtil jwtUtil; + private final UserRepository userRepository; + + public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, CustomFailureHandler customFailureHandler, JWTUtil jwtUtil, UserRepository userRepository) { + this.customOAuth2UserService = customOAuth2UserService; + this.customSuccessHandler = customSuccessHandler; + this.customFailureHandler = customFailureHandler; + this.jwtUtil = jwtUtil; + this.userRepository = userRepository; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + + // cors ์„ค์ • + http + .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Collections.singletonList("http://localhost:5173")); + configuration.setAllowedMethods(Arrays.asList( + "GET","POST","PUT","PATCH","DELETE","OPTIONS" + )); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + +// configuration.setExposedHeaders(Collections.singletonList("Set-Cookie")); +// configuration.setExposedHeaders(Collections.singletonList("Authorization")); + configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization")); + + return configuration; + } + })); + + // csrf disable + http + .csrf((auth) -> auth.disable()); + + // Form ๋กœ๊ทธ์ธ ๋ฐฉ์‹ disable + http + .formLogin((auth) -> auth.disable()); + + // HTTP Basic ์ธ์ฆ ๋ฐฉ์‹ disable + http + .httpBasic((auth) -> auth.disable()); + + + // JWTFilter ์ถ”๊ฐ€ + http + .addFilterAfter(new JWTFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class); + + // ProfileCheckFilter ์ถ”๊ฐ€ + http + .addFilterAfter(new ProfileCheckFilter(userRepository), OAuth2LoginAuthenticationFilter.class); + + // oauth2 + + http + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint((UserInfoEndpointConfig) -> UserInfoEndpointConfig + .userService(customOAuth2UserService)) + .successHandler(customSuccessHandler) + .failureHandler(customFailureHandler) + ); + + // ๊ฒฝ๋กœ๋ณ„ ์ธ๊ฐ€ ์ž‘์—… + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/", "/login", "/auth/**", "/error", "/signup",// /error ์ถ”๊ฐ€ + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**").permitAll() + .anyRequest().authenticated()); + + // ์„ธ์…˜ ์„ค์ • : STATELESS + http + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/src/main/java/org/example/studylog/config/SwaggerConfig.java b/src/main/java/org/example/studylog/config/SwaggerConfig.java new file mode 100644 index 0000000..f519571 --- /dev/null +++ b/src/main/java/org/example/studylog/config/SwaggerConfig.java @@ -0,0 +1,33 @@ +package org.example.studylog.config; + +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI(){ + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + SecurityRequirement securityRequirement = new SecurityRequirement().addList("bearerAuth"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) + .addSecurityItem(securityRequirement) + .info(apiInfo()); + } + + private Info apiInfo(){ + return new Info() + .title("Study Log API") + .description("์Šคํ„ฐ๋””๋กœ๊ทธ์˜ API์— ๋Œ€ํ•œ ๋ช…์„ธ์„œ") + .version("1.0.0"); + } +} diff --git a/src/main/java/org/example/studylog/controller/.gitkeep b/src/main/java/org/example/studylog/controller/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/controller/CategoryController.java b/src/main/java/org/example/studylog/controller/CategoryController.java new file mode 100644 index 0000000..6d73286 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/CategoryController.java @@ -0,0 +1,157 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.category.CreateCategoryRequestDTO; +import org.example.studylog.dto.category.UpdateCategoryRequestDTO; +import org.example.studylog.dto.category.CategoryResponseDTO; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.CategoryService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/categories") +@RequiredArgsConstructor +@Validated +@Slf4j +@Tag(name = "Categories", description = "์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ API") +public class CategoryController { + + private final CategoryService categoryService; + private final UserRepository userRepository; + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\"status\": 201, \"message\": \"์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค\", \"data\": {\"id\": 1, \"name\": \"Spring Boot\", \"color\": \"#FF5733\"}}"))), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ (์ค‘๋ณต๋œ ์นดํ…Œ๊ณ ๋ฆฌ๋ช… ๋“ฑ)", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @PostMapping + public ResponseEntity createCategory( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Valid @RequestBody CreateCategoryRequestDTO requestDTO) { + + try { + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, ์ด๋ฆ„={}", currentUser.getName(), requestDTO.getName()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + CategoryResponseDTO responseDTO = categoryService.createCategory(user, requestDTO); + + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์„ฑ๊ณต: ID={}, ์ด๋ฆ„={}", responseDTO.getId(), responseDTO.getName()); + + return ResponseUtil.buildResponse(201, "์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ", description = "์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\"status\": 200, \"message\": \"์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต\", \"data\": [{\"id\": 1, \"name\": \"Spring Boot\", \"color\": \"#FF5733\"}, {\"id\": 2, \"name\": \"React\", \"color\": \"#61DAFB\"}]}"))), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @GetMapping + public ResponseEntity getCategories( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser) { + + try { + log.info("์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ: ์‚ฌ์šฉ์ž={}", currentUser.getName()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + List categories = categoryService.getUserCategories(user); + + return ResponseUtil.buildResponse(200, "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ ์„ฑ๊ณต", categories); + + } catch (Exception e) { + log.error("์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ •", description = "๊ธฐ์กด ์นดํ…Œ๊ณ ๋ฆฌ์˜ ์ •๋ณด๋ฅผ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์ˆ˜์ • ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\"status\": 200, \"message\": \"์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค\", \"data\": {\"id\": 1, \"name\": \"Spring Framework\", \"color\": \"#6DB33F\"}}"))), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ (์ค‘๋ณต๋œ ์นดํ…Œ๊ณ ๋ฆฌ๋ช… ๋“ฑ)", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @PutMapping("/{categoryId}") + public ResponseEntity updateCategory( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "์นดํ…Œ๊ณ ๋ฆฌ ID", example = "1") @PathVariable Long categoryId, + @Valid @RequestBody UpdateCategoryRequestDTO requestDTO) { + + try { + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, categoryId={}, ์ƒˆ์ด๋ฆ„={}", + currentUser.getName(), categoryId, requestDTO.getName()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + CategoryResponseDTO responseDTO = categoryService.updateCategory(user, categoryId, requestDTO); + + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์„ฑ๊ณต: ID={}, ์ƒˆ์ด๋ฆ„={}", responseDTO.getId(), responseDTO.getName()); + + return ResponseUtil.buildResponse(200, "์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/controller/FriendController.java b/src/main/java/org/example/studylog/controller/FriendController.java new file mode 100644 index 0000000..77aad7f --- /dev/null +++ b/src/main/java/org/example/studylog/controller/FriendController.java @@ -0,0 +1,107 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.ProfileResponseDTO; +import org.example.studylog.dto.friend.FriendNameDTO; +import org.example.studylog.dto.friend.FriendRequestDTO; +import org.example.studylog.dto.friend.FriendResponseDTO; +import org.example.studylog.service.FriendService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/friends") +public class FriendController { + + private final FriendService friendService; + + @Operation(summary = "code๋กœ ์นœ๊ตฌ ์กฐํšŒ", description = "์นœ๊ตฌ ์ถ”๊ฐ€ ์‹œ, code๋กœ ์นœ๊ตฌ ์กฐํšŒํ•˜๋Š” API") + @ApiResponse(responseCode = "200", description = "์‚ฌ์šฉ์ž ์ด๋ฆ„ ์กฐํšŒ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = FriendNameDTO.class))) + @GetMapping("by-code") + public ResponseEntity findUserByCode(@RequestParam String code) { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + FriendNameDTO dto = friendService.findUserByCode(oauthId, code); + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ์ด๋ฆ„ ์กฐํšŒ ์™„๋ฃŒ", dto); + } + + @Operation(summary = "์นœ๊ตฌ ๋ชฉ๋ก ์กฐํšŒ", description = "๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ์นœ๊ตฌ ๋ชฉ๋ก ์กฐํšŒ API") + @ApiResponse(responseCode = "200", description = "์นœ๊ตฌ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = FriendResponseDTO.class)))) + @GetMapping + public ResponseEntity getFriendList(){ + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + List friends = friendService.getFriendList(oauthId); + return ResponseUtil.buildResponse(200, "์นœ๊ตฌ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", friends); + } + + @Operation(summary = "์นœ๊ตฌ ๊ฒ€์ƒ‰", description = "์นœ๊ตฌ ๋ชฉ๋ก์—์„œ ์ด๋ฆ„์œผ๋กœ ์นœ๊ตฌ ์กฐํšŒ API") + @ApiResponse(responseCode = "200", description = "{query}์— ๋Œ€ํ•œ ์นœ๊ตฌ ๊ฒ€์ƒ‰ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = FriendResponseDTO.class)))) + @GetMapping("/search") + public ResponseEntity getFriendByQuery(@RequestParam String query){ + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + List friends = friendService.getFriendByQuery(oauthId, query); + return ResponseUtil.buildResponse(200, String.format("\'%s\'์— ๋Œ€ํ•œ ์นœ๊ตฌ ๊ฒ€์ƒ‰ ์™„๋ฃŒ", query), friends); + } + + @Operation(summary = "code๋กœ ์นœ๊ตฌ ์ถ”๊ฐ€", description = "code๋กœ ์นœ๊ตฌ ์ถ”๊ฐ€ API") + @ApiResponse(responseCode = "201", description = "์นœ๊ตฌ ์ถ”๊ฐ€ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json")) + @PostMapping + public ResponseEntity addFriend(@RequestBody @Valid FriendRequestDTO request) { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + friendService.addFriend(request, oauthId); + return ResponseUtil.buildResponse(201, "์นœ๊ตฌ ์ถ”๊ฐ€ ์™„๋ฃŒ", null); + } + + @Operation(summary = "์นœ๊ตฌ ์‚ญ์ œ", description = "friendId๋กœ ์นœ๊ตฌ ์‚ญ์ œ API") + @ApiResponse(responseCode = "200", description = "์นœ๊ตฌ ์‚ญ์ œ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = FriendResponseDTO.class))) + @DeleteMapping("/{friendId}") + public ResponseEntity deleteFriend(@PathVariable Long friendId){ + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + FriendResponseDTO dto = friendService.deleteFriend(oauthId, friendId); + return ResponseUtil.buildResponse(200, "์นœ๊ตฌ ์‚ญ์ œ ์™„๋ฃŒ", dto); + } + +} + diff --git a/src/main/java/org/example/studylog/controller/LoginController.java b/src/main/java/org/example/studylog/controller/LoginController.java new file mode 100644 index 0000000..2006625 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/LoginController.java @@ -0,0 +1,35 @@ +package org.example.studylog.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@Controller +public class LoginController { + + @GetMapping("/") + public String index(){ + return "index.html"; + } + + // ๋ฉ”์ธ ํŽ˜์ด์ง€ ๊ตฌํ˜„์— ๋”ฐ๋ผ ์ฃผ์„ ์ฒ˜๋ฆฌ - ์ฑ„๋ฏผ +// @GetMapping("/main") +// @ResponseBody +// public String main() { +// return "๋ฉ”์ธ ํŽ˜์ด์ง€"; +// } + + @GetMapping("/success") + @ResponseBody + public String success(){ + return "์š”์ฒญ ์„ฑ๊ณต!"; + } + + @GetMapping("/signup") + @ResponseBody + public String signup(){ + return "ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€"; + } + + +} diff --git a/src/main/java/org/example/studylog/controller/MainController.java b/src/main/java/org/example/studylog/controller/MainController.java new file mode 100644 index 0000000..312b648 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/MainController.java @@ -0,0 +1,179 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.MainService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Main", description = "๋ฉ”์ธ ํŽ˜์ด์ง€ API") +public class MainController { + + private final MainService mainService; + private final UserRepository userRepository; + + @Operation( + summary = "๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ", + description = "๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ๋˜๋Š” ๊ณต์œ  ์ฝ”๋“œ๋กœ ์กฐํšŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 200," + + "\"message\": \"๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.\"," + + "\"data\": {" + + "\"following\": []," + + "\"profile\": {" + + "\"userId\": 1," + + "\"coverImage\": null," + + "\"profileImage\": \"https://example.com/profile.jpg\"," + + "\"name\": \"ํ™๊ธธ๋™\"," + + "\"intro\": \"์•ˆ๋…•ํ•˜์„ธ์š”! ๊ฐœ๋ฐœ ๊ณต๋ถ€ ์ค‘์ž…๋‹ˆ๋‹ค.\"," + + "\"level\": 5," + + "\"code\": \"ABC123\"" + + "}," + + "\"streak\": {" + + "\"maxStreak\": 15," + + "\"recordCountPerDay\": {" + + "\"2025-01-10\": 2," + + "\"2025-01-11\": 1," + + "\"2025-01-12\": 3" + + "}" + + "}," + + "\"categories\": [" + + "{\"name\": \"Spring Boot\", \"count\": 25}," + + "{\"name\": \"React\", \"count\": 18}" + + "]," + + "\"isFollowing\": true" + + "}" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ (์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 401," + + "\"message\": \"์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.\"," + + "\"data\": false" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "์‚ฌ์šฉ์ž ์ฝ”๋“œ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 404," + + "\"message\": \"์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.\"," + + "\"data\": null" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 500," + + "\"message\": \"๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.\"," + + "\"data\": null" + + "}" + ) + ) + ) + }) + @GetMapping("/main") + public ResponseEntity getMainPage( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "์‚ฌ์šฉ์ž ๊ณต์œ  ์ฝ”๋“œ (์„ ํƒ์ )", example = "ABC123") @RequestParam(required = false) String code) { + + // code ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์ฝ”๋“œ๋กœ ์กฐํšŒ, ์—†์œผ๋ฉด ๊ธฐ์กด ๋กœ์ง + if (code != null && !code.trim().isEmpty()) { + return getMainPageByCode(code, currentUser); // private ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ + } + + // ๊ธฐ์กด ๋กœ์ง (์ธ์ฆ๋œ ์‚ฌ์šฉ์ž) + try { + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}", currentUser.getName()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", false); + } + + Object mainPageData = mainService.getMainPageData(user); + + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์„ฑ๊ณต: ์‚ฌ์šฉ์ž={}", currentUser.getName()); + + return ResponseUtil.buildResponse(200, "๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.", mainPageData); + + } catch (Exception e) { + log.error("๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.", null); + } + } + + // ๋™์ผ ์—”๋“œํฌ์ธํŠธ์—์„œ ์ฟผ๋ฆฌ ์ง€์›์„ ์œ„ํ•ด private ๋ฉ”์„œ๋“œ๋กœ ๋ณ€๊ฒฝ + private ResponseEntity getMainPageByCode(String code, CustomOAuth2User currentUser) { + try { + log.info("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์š”์ฒญ: code={}", code); + + User targetUser = userRepository.findByCode(code) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.")); + + // ํ˜„์žฌ ์‚ฌ์šฉ์ž๊ฐ€ ์žˆ์œผ๋ฉด ํŒ”๋กœ์šฐ ์—ฌ๋ถ€ ํ™•์ธ + User currentUserEntity = null; + if (currentUser != null) { + currentUserEntity = userRepository.findByOauthId(currentUser.getName()); + } + + Object mainPageData = mainService.getMainPageDataWithFollowStatus(targetUser, currentUserEntity); + + log.info("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์„ฑ๊ณต: code={}, ์‚ฌ์šฉ์ž={}", code, targetUser.getOauthId()); + + return ResponseUtil.buildResponse(200, "๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.", mainPageData); + + } catch (IllegalArgumentException e) { + log.warn("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(404, e.getMessage(), null); + + } catch (Exception e) { + log.error("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.", null); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/controller/NotificationController.java b/src/main/java/org/example/studylog/controller/NotificationController.java new file mode 100644 index 0000000..bf95994 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/NotificationController.java @@ -0,0 +1,62 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.ProfileResponseDTO; +import org.example.studylog.dto.notification.NotificationListResponseDTO; +import org.example.studylog.service.NotificationService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "SSE ๊ตฌ๋…") + @ApiResponse(content = @Content(schema = @Schema(implementation = SseEmitter.class))) + @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter subscribe() { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + return notificationService.createEmitter(oauthId); + } + + @Operation(summary = "์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ") + @ApiResponse(responseCode = "200", description = "์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = NotificationListResponseDTO.class)))) + @GetMapping("/notifications") + public ResponseEntity getNotificationList( + @Parameter( + description = "true๋ฉด ์ฝ์Œ ์ฒ˜๋ฆฌ, false๋ฉด ๊ธฐ๋ณธ ์กฐํšŒ", + example = "true" + ) + @RequestParam boolean isRead) { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + List list = notificationService.getNotificationList(oauthId, isRead); + return ResponseUtil.buildResponse(200, "์•Œ๋ฆผ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", list); + } + +} diff --git a/src/main/java/org/example/studylog/controller/QuizController.java b/src/main/java/org/example/studylog/controller/QuizController.java new file mode 100644 index 0000000..119316f --- /dev/null +++ b/src/main/java/org/example/studylog/controller/QuizController.java @@ -0,0 +1,122 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.BackgroundDTO; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.dto.quiz.CreateQuizRequestDTO; +import org.example.studylog.dto.quiz.QuizListResponseDTO; +import org.example.studylog.dto.quiz.QuizResponseDTO; +import org.example.studylog.dto.studyrecord.CreateStudyRecordResponseDTO; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.BusinessException; +import org.example.studylog.service.QuizService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/quizzes") +@RequiredArgsConstructor +public class QuizController { + + private final QuizService quizService; + + @Operation(summary = "ํ€ด์ฆˆ ์ƒ์„ฑ", description = "recordId๋กœ ์นœ๊ตฌ ์ƒ์„ฑ API") + @ApiResponse(responseCode = "200", description = "ํ€ด์ฆˆ ์ƒ์„ฑ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + array = @ArraySchema(schema = @Schema(implementation = QuizResponseDTO.class)))) + @PostMapping("/{recordId}") + public ResponseEntity createQuiz( + @AuthenticationPrincipal CustomOAuth2User currentUser, + @PathVariable Long recordId, + @Valid @RequestBody CreateQuizRequestDTO requestDTO) { + + try { + log.info("ํ€ด์ฆˆ ์ƒ์„ฑ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, ๊ธฐ๋กID={}", currentUser.getName(), recordId); + + List list = quizService.createQuiz(currentUser.getName(), recordId, requestDTO); + log.info("ํ€ด์ฆˆ ์ƒ์„ฑ ์„ฑ๊ณต: ๊ธฐ๋กID={}", recordId); + + return ResponseUtil.buildResponse(200, "ํ€ด์ฆˆ ์ƒ์„ฑ ์™„๋ฃŒ", list); + + } catch (BusinessException e) { + log.warn("ํ€ด์ฆˆ ์ƒ์„ฑ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("ํ€ด์ฆˆ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ", description = "quizId๋กœ ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ API") + @ApiResponse(responseCode = "200", description = "ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = QuizResponseDTO.class))) + @GetMapping("/{quizId}") + public ResponseEntity getQuiz(@AuthenticationPrincipal CustomOAuth2User currentUser, + @PathVariable Long quizId){ + try { + log.info("ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ: ์‚ฌ์šฉ์ž={}, ํ€ด์ฆˆID={}", currentUser.getName(), quizId); + + QuizResponseDTO dto = quizService.getQuiz(currentUser.getName(), quizId); + + return ResponseUtil.buildResponse(200, "ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ ์™„๋ฃŒ", dto); + } catch (IllegalArgumentException e){ + log.warn("ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + } catch (Exception e) { + log.error("ํ€ด์ฆˆ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ", description = "query, date, categoryId๋กœ ํ€ด์ฆˆ ์ƒ์„ธ ์กฐํšŒ API") + @ApiResponse(responseCode = "200", description = "ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = QuizListResponseDTO.class))) + @GetMapping + public ResponseEntity getQuizList( + @AuthenticationPrincipal CustomOAuth2User currentUser, + @RequestParam(required = false) String query, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long lastId, + @RequestParam(defaultValue = "10") int size + ) { + QuizListResponseDTO quizzes = quizService.getQuizList( + currentUser.getName(), query, lastId, size, date, categoryId + ); + + try{ + log.info("ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ: query = {}, date = {}, categoryId = {}, lastId = {}, szize = {}", + query, date, categoryId, lastId, size); + + return ResponseUtil.buildResponse(200, "ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์™„๋ฃŒ", quizzes); + + } catch (IllegalArgumentException e){ + log.warn("ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + } catch (Exception e) { + log.error("ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + + } +} diff --git a/src/main/java/org/example/studylog/controller/StreakController.java b/src/main/java/org/example/studylog/controller/StreakController.java new file mode 100644 index 0000000..cf84cc4 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/StreakController.java @@ -0,0 +1,193 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.StreakService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Streak", description = "์ŠคํŠธ๋ฆญ(์—ฐ์† ํ•™์Šต) ๊ด€๋ จ API") +public class StreakController { + + private final StreakService streakService; + private final UserRepository userRepository; + + @Operation( + summary = "์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ", + description = "ํŠน์ • ์—ฐ๋„์™€ ์›”์˜ ์ผ๋ณ„ ํ•™์Šต ๊ธฐ๋ก ๊ฐœ์ˆ˜๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ์ธ์ฆ๋œ ์‚ฌ์šฉ์ž ๋˜๋Š” ๊ณต์œ  ์ฝ”๋“œ๋กœ ์กฐํšŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "์ŠคํŠธ๋ฆญ ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 200," + + "\"message\": \"์ŠคํŠธ๋ฆญ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.\"," + + "\"data\": {" + + "\"2025-07-01\": 0," + + "\"2025-07-02\": 3," + + "\"2025-07-03\": 1," + + "\"2025-07-04\": 0," + + "\"2025-07-05\": 2" + + "}" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "์ž˜๋ชป๋œ ์š”์ฒญ (์œ ํšจํ•˜์ง€ ์•Š์€ ๋…„๋„/์›”)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 400," + + "\"message\": \"์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค\"," + + "\"data\": {" + + "\"example\": \"์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์›”์ž…๋‹ˆ๋‹ค\"" + + "}" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "์ธ์ฆ ์‹คํŒจ (์ ‘๊ทผ ๊ถŒํ•œ ์—†์Œ)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 401," + + "\"message\": \"์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.\"," + + "\"data\": false" + + "}" + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = "{" + + "\"status\": 500," + + "\"message\": \"๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.\"," + + "\"data\": null" + + "}" + ) + ) + ) + }) + @GetMapping("/streak") + public ResponseEntity getMonthlyStreak( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "์‚ฌ์šฉ์ž ๊ณต์œ  ์ฝ”๋“œ (์„ ํƒ์ )", example = "ABC123") @RequestParam(required = false) String code, + @Parameter(description = "์กฐํšŒํ•  ๋…„๋„", required = true, example = "2025") @RequestParam("year") String year, + @Parameter(description = "์กฐํšŒํ•  ์›” (1-12)", required = true, example = "7") @RequestParam("month") String month) { + + // code ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์ฝ”๋“œ๋กœ ์กฐํšŒ + if (code != null && !code.trim().isEmpty()) { + return getMonthlyStreakByCode(code, year, month); // private ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ + } + + // ๊ธฐ์กด ๋กœ์ง (์ธ์ฆ๋œ ์‚ฌ์šฉ์ž) + try { + log.info("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, year={}, month={}", + currentUser.getName(), year, month); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.", false); + } + + validateYearMonth(year, month); + Object monthlyStreakData = streakService.getMonthlyStreakData(user, year, month); + + log.info("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์„ฑ๊ณต: ์‚ฌ์šฉ์ž={}, year={}, month={}", + currentUser.getName(), year, month); + + return ResponseUtil.buildResponse(200, "์ŠคํŠธ๋ฆญ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.", monthlyStreakData); + + } catch (IllegalArgumentException e) { + log.warn("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, "์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค", Map.of("example", e.getMessage())); + + } catch (Exception e) { + log.error("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.", null); + } + } + + // private ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ + private ResponseEntity getMonthlyStreakByCode(String code, String year, String month) { + try { + log.info("์ฝ”๋“œ๋กœ ์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์š”์ฒญ: code={}, year={}, month={}", code, year, month); + + User user = userRepository.findByCode(code) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.")); + + validateYearMonth(year, month); + Object monthlyStreakData = streakService.getMonthlyStreakData(user, year, month); + + log.info("์ฝ”๋“œ๋กœ ์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์„ฑ๊ณต: code={}, ์‚ฌ์šฉ์ž={}, year={}, month={}", + code, user.getOauthId(), year, month); + + return ResponseUtil.buildResponse(200, "์ŠคํŠธ๋ฆญ ์กฐํšŒ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค.", monthlyStreakData); + + } catch (IllegalArgumentException e) { + log.warn("์ฝ”๋“œ๋กœ ์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, "์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค", Map.of("example", e.getMessage())); + + } catch (Exception e) { + log.error("์ฝ”๋“œ๋กœ ์›”๋ณ„ ์ŠคํŠธ๋ฆญ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•ด์ฃผ์„ธ์š”.", null); + } + } + + private void validateYearMonth(String year, String month) { + // ๋…„๋„ ๊ฒ€์ฆ + try { + int yearInt = Integer.parseInt(year); + if (yearInt < 2020 || yearInt > 2030) { + throw new IllegalArgumentException("์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๋…„๋„์ž…๋‹ˆ๋‹ค"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๋…„๋„ ํ˜•์‹์ž…๋‹ˆ๋‹ค"); + } + + // ์›” ๊ฒ€์ฆ + try { + int monthInt = Integer.parseInt(month); + if (monthInt < 1 || monthInt > 12) { + throw new IllegalArgumentException("์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์›”์ž…๋‹ˆ๋‹ค"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์›” ํ˜•์‹์ž…๋‹ˆ๋‹ค"); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/controller/StudyRecordController.java b/src/main/java/org/example/studylog/controller/StudyRecordController.java new file mode 100644 index 0000000..02fda53 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/StudyRecordController.java @@ -0,0 +1,289 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.dto.studyrecord.*; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.StudyRecordService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +import java.util.Map; + +@RestController +@RequestMapping("/records") +@RequiredArgsConstructor +@Validated +@Slf4j +@Tag(name = "Study Records", description = "ํ•™์Šต ๊ธฐ๋ก ๊ด€๋ จ API") +public class StudyRecordController { + + private final StudyRecordService studyRecordService; + private final UserRepository userRepository; + + @Operation(summary = "ํ•™์Šต ๊ธฐ๋ก ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ํ•™์Šต ๊ธฐ๋ก์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "ํ•™์Šต ๊ธฐ๋ก ์ƒ์„ฑ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\"status\": 201, \"message\": \"๊ธฐ๋ก ์ƒ์„ฑ ์„ฑ๊ณต\", \"data\": {\"recordId\": 1, \"title\": \"Spring Boot ํ•™์Šต\"}}"))), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @PostMapping + public ResponseEntity createStudyRecord( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Valid @RequestBody CreateStudyRecordRequestDTO requestDTO) { + + try { + log.info("๊ธฐ๋ก ์ƒ์„ฑ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, ์ œ๋ชฉ={}", currentUser.getName(), requestDTO.getTitle()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + CreateStudyRecordResponseDTO responseDTO = studyRecordService.createStudyRecord(user, requestDTO); + + log.info("๊ธฐ๋ก ์ƒ์„ฑ ์„ฑ๊ณต: ๊ธฐ๋กID={}, ์ŠคํŠธ๋ฆญ={}", + responseDTO.getRecord().getId(), responseDTO.getStreak().getCurrentStreak()); + + return ResponseUtil.buildResponse(201, "ํ•™์Šต ๊ธฐ๋ก์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ์ƒ์„ฑ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "ํ•™์Šต ๊ธฐ๋ก ์ƒ์„ธ ์กฐํšŒ", description = "ํŠน์ • ํ•™์Šต ๊ธฐ๋ก์˜ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์กฐํšŒ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @GetMapping("/{recordId}") + public ResponseEntity getStudyRecord( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "ํ•™์Šต ๊ธฐ๋ก ID", example = "1") @PathVariable Long recordId) { + + try { + log.info("๊ธฐ๋ก ์ƒ์„ธ ์กฐํšŒ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, recordId={}", currentUser.getName(), recordId); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + StudyRecordDetailResponseDTO responseDTO = studyRecordService.getStudyRecordDetail(user, recordId); + + log.info("๊ธฐ๋ก ์ƒ์„ธ ์กฐํšŒ ์„ฑ๊ณต: recordId={}", recordId); + + return ResponseUtil.buildResponse(200, "๊ธฐ๋ก ์ƒ์„ธ ์ •๋ณด๋ฅผ ์„ฑ๊ณต์ ์œผ๋กœ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "ํ•™์Šต ๊ธฐ๋ก ์ˆ˜์ •", description = "๊ธฐ์กด ํ•™์Šต ๊ธฐ๋ก์„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์ˆ˜์ • ์„ฑ๊ณต", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @PutMapping("/{recordId}") + public ResponseEntity updateStudyRecord( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "ํ•™์Šต ๊ธฐ๋ก ID", example = "1") @PathVariable Long recordId, + @Valid @RequestBody UpdateStudyRecordRequestDTO requestDTO) { + + try { + log.info("๊ธฐ๋ก ์ˆ˜์ • ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, recordId={}, ์ œ๋ชฉ={}", + currentUser.getName(), recordId, requestDTO.getTitle()); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + StudyRecordDTO responseDTO = studyRecordService.updateStudyRecord(user, recordId, requestDTO); + + log.info("๊ธฐ๋ก ์ˆ˜์ • ์„ฑ๊ณต: recordId={}", recordId); + + return ResponseUtil.buildResponse(200, "๊ธฐ๋ก์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ์ˆ˜์ • ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "ํ•™์Šต ๊ธฐ๋ก ์‚ญ์ œ", description = "ํŠน์ • ํ•™์Šต ๊ธฐ๋ก์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "์‚ญ์ œ ์„ฑ๊ณต", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "๊ธฐ๋ก์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜", + content = @Content(mediaType = "application/json")) + }) + @DeleteMapping("/{recordId}") + public ResponseEntity deleteStudyRecord( + @Parameter(hidden = true) @AuthenticationPrincipal CustomOAuth2User currentUser, + @Parameter(description = "ํ•™์Šต ๊ธฐ๋ก ID", example = "1") @PathVariable Long recordId) { + + try { + log.info("๊ธฐ๋ก ์‚ญ์ œ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, recordId={}", currentUser.getName(), recordId); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + studyRecordService.deleteStudyRecord(user, recordId); + + log.info("๊ธฐ๋ก ์‚ญ์ œ ์„ฑ๊ณต: recordId={}", recordId); + + return ResponseUtil.buildResponse(200, "๊ธฐ๋ก์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค", null); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ์‚ญ์ œ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "์ œ๋ชฉ์œผ๋กœ ๊ธฐ๋ก ๊ฒ€์ƒ‰", description = "์ œ๋ชฉ์œผ๋กœ ํ•™์Šต ๊ธฐ๋ก์„ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค") + @GetMapping("/search") + public ResponseEntity searchStudyRecords( + @AuthenticationPrincipal CustomOAuth2User currentUser, + @RequestParam("query") String query) { + + try { + log.info("๊ธฐ๋ก ๊ฒ€์ƒ‰ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, ๊ฒ€์ƒ‰์–ด={}", currentUser.getName(), query); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + StudyRecordListResponseDTO responseDTO = studyRecordService.searchStudyRecordsByTitle(user, query); + + log.info("๊ธฐ๋ก ๊ฒ€์ƒ‰ ์„ฑ๊ณต: ๊ฒ€์ƒ‰๊ฒฐ๊ณผ={}๊ฑด", responseDTO.getRecords().size()); + + return ResponseUtil.buildResponse(200, "๊ธฐ๋ก ๋ชฉ๋ก์„ ์„ฑ๊ณต์ ์œผ๋กœ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ๊ฒ€์ƒ‰ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, "์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค", + Map.of("example", e.getMessage())); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + @Operation(summary = "์นดํ…Œ๊ณ ๋ฆฌ/๋‚ ์งœ๋กœ ๊ธฐ๋ก ์กฐํšŒ", + description = "์นดํ…Œ๊ณ ๋ฆฌ, ๋‚ ์งœ ์กฐ๊ฑด์œผ๋กœ ํ•™์Šต ๊ธฐ๋ก์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. ๋ฌดํ•œ ์Šคํฌ๋กค์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.") + @GetMapping + public ResponseEntity getStudyRecordsWithFilter( + @AuthenticationPrincipal CustomOAuth2User currentUser, + @RequestParam(value = "categoryId", required = false) Long categoryId, + @RequestParam(value = "date", required = false) String date, + @RequestParam(value = "lastId", required = false) Long lastId, + @RequestParam(value = "size", defaultValue = "10") Integer size) { + + try { + log.info("๊ธฐ๋ก ํ•„ํ„ฐ๋ง ์กฐํšŒ ์š”์ฒญ: ์‚ฌ์šฉ์ž={}, categoryId={}, date={}, lastId={}, size={}", + currentUser.getName(), categoryId, date, lastId, size); + + User user = userRepository.findByOauthId(currentUser.getName()); + if (user == null) { + return ResponseUtil.buildResponse(401, "์œ ํšจํ•˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค", null); + } + + // ์š”์ฒญ DTO ์ƒ์„ฑ + StudyRecordFilterRequestDTO requestDTO = new StudyRecordFilterRequestDTO(); + requestDTO.setCategoryId(categoryId); + requestDTO.setDate(date); + requestDTO.setLastId(lastId); + requestDTO.setSize(size); + + StudyRecordFilterResponseDTO responseDTO = studyRecordService.getStudyRecordsWithFilter(user, requestDTO); + + log.info("๊ธฐ๋ก ํ•„ํ„ฐ๋ง ์กฐํšŒ ์„ฑ๊ณต: ๊ฒฐ๊ณผ={}๊ฑด, hasMore={}", + responseDTO.getRecords().size(), responseDTO.getHasMore()); + + return ResponseUtil.buildResponse(200, "๊ธฐ๋ก ๋ชฉ๋ก์„ ์„ฑ๊ณต์ ์œผ๋กœ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค", responseDTO); + + } catch (IllegalArgumentException e) { + log.warn("๊ธฐ๋ก ํ•„ํ„ฐ๋ง ์กฐํšŒ ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, "์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค", + Map.of("example", e.getMessage())); + + } catch (Exception e) { + log.error("๊ธฐ๋ก ํ•„ํ„ฐ๋ง ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } + + + @Operation(summary = "ํ…Œ์ŠคํŠธ ์—”๋“œํฌ์ธํŠธ", description = "API ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ์šฉ ์—”๋“œํฌ์ธํŠธ") + @ApiResponse(responseCode = "200", description = "ํ…Œ์ŠคํŠธ ์„ฑ๊ณต") + @GetMapping("/test") + public ResponseEntity testEndpoint() { + log.info("=== TEST ์—”๋“œํฌ์ธํŠธ ํ˜ธ์ถœ๋จ ==="); + return ResponseUtil.buildResponse(200, "Controller ์—ฐ๊ฒฐ ์„ฑ๊ณต!", "ํ…Œ์ŠคํŠธ ์„ฑ๊ณต"); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/controller/UserController.java b/src/main/java/org/example/studylog/controller/UserController.java new file mode 100644 index 0000000..7943d29 --- /dev/null +++ b/src/main/java/org/example/studylog/controller/UserController.java @@ -0,0 +1,124 @@ +package org.example.studylog.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.*; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.dto.oauth.TokenDTO; +import org.example.studylog.service.UserService; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + + private final UserService userService; + + @Operation(summary = "ํ”„๋กœํ•„ ์ƒ์„ฑ", description = "ํ”„๋กœํ•„ ์ƒ์„ฑ์„ ์œ„ํ•œ api") + @ApiResponse( + responseCode = "200", + description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema( + implementation = ProfileResponseDTO.class + ))) + @PostMapping(path = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity createProfile(@Valid @ModelAttribute ProfileCreateRequestDTO request) { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + log.info("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ ์‹œ์ž‘: oauthId = {}", oauthId); + + ProfileResponseDTO dto = userService.createUserProfile(request, oauthId); + log.info("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ ์™„๋ฃŒ: profileImage = {}, nickname = {}, intro = {}", + dto.getProfileImage(), dto.getNickname(), dto.getIntro()); + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ ์™„๋ฃŒ", dto); + } + + @Operation(summary = "ํ”„๋กœํ•„ ์ˆ˜์ •", description = "ํ”„๋กœํ•„ ์ˆ˜์ •์„ ์œ„ํ•œ api") + @ApiResponse(responseCode = "200", description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ˆ˜์ • ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProfileResponseDTO.class))) + @PatchMapping(path = "/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateProfile(@ModelAttribute ProfileUpdateRequestDTO request) { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + ProfileResponseDTO dto = userService.updateUserProfile(request, oauthId); + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ˆ˜์ • ์™„๋ฃŒ", dto); + } + + @Operation(summary = "ํ”„๋กœํ•„ ์กฐํšŒ") + @GetMapping("/profile") + @ApiResponse(responseCode = "200", description = "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProfileResponseDTO.class))) + public ResponseEntity getProfile() { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + ProfileResponseDTO dto = userService.getUserProfile(oauthId); + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ ์„ฑ๊ณต", dto); + } + + @Operation(summary = "๋กœ๊ทธ์ธ ์œ ์ €์˜ ๋งˆ์ดํŽ˜์ด์ง€ ์กฐํšŒ") + @ApiResponse(responseCode = "200", description = "์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ์„ฑ๊ณต", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = UserInfoResponseDTO.class))) + @GetMapping + public ResponseEntity getUserInfo() { + // ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž oauthId ๊ฐ€์ ธ์˜ค๊ธฐ + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String oauthId = auth.getName(); + + UserInfoResponseDTO dto = userService.getUserInfo(oauthId); + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ ์„ฑ๊ณต", dto); + } + + @Operation(summary = "๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ •") + @PatchMapping(path = "/background", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ApiResponse(responseCode = "200", description = "์‚ฌ์šฉ์ž ๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BackgroundDTO.ResponseDTO.class))) + public ResponseEntity updateBackground( + @AuthenticationPrincipal CustomOAuth2User currentUser, + @Valid @ModelAttribute BackgroundDTO.RequestDTO dto) { + try{ + log.info("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์š”์ฒญ: ์‚ฌ์šฉ์ž={}", currentUser.getName()); + + BackgroundDTO.ResponseDTO responseDTO = userService.updateBackground(currentUser.getName(), dto); + + log.info("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์„ฑ๊ณต: ์‚ฌ์šฉ์ž={}, ๋ฐฐ๊ฒฝํ™”๋ฉด={}", currentUser.getName(), responseDTO.getCoverImage()); + + return ResponseUtil.buildResponse(200, "์‚ฌ์šฉ์ž ๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์™„๋ฃŒ", responseDTO); + } catch (IllegalStateException e){ + log.warn("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์‹คํŒจ - ์ž˜๋ชป๋œ ์š”์ฒญ: {}", e.getMessage()); + return ResponseUtil.buildResponse(400, e.getMessage(), null); + } catch (Exception e){ + log.error("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e); + return ResponseUtil.buildResponse(500, "๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค", null); + } + } +} diff --git a/src/main/java/org/example/studylog/controller/jwt/AuthController.java b/src/main/java/org/example/studylog/controller/jwt/AuthController.java new file mode 100644 index 0000000..91d105f --- /dev/null +++ b/src/main/java/org/example/studylog/controller/jwt/AuthController.java @@ -0,0 +1,81 @@ +package org.example.studylog.controller.jwt; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.example.studylog.dto.ResponseDTO; +import org.example.studylog.dto.oauth.TokenDTO; +import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.service.TokenService; +import org.example.studylog.util.CookieUtil; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final JWTUtil jwtUtil; + private final TokenService tokenService; + + public AuthController(JWTUtil jwtUtil, TokenService tokenService) { + this.jwtUtil = jwtUtil; + this.tokenService = tokenService; + } + + @Operation(summary = "AccessToken ์žฌ๋ฐœ๊ธ‰ API", + parameters = { + @Parameter( + in = ParameterIn.COOKIE, + name = "refresh", + required = true, + description = "๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ" + ) + }) + @ApiResponse( + responseCode = "200", + description = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์™„๋ฃŒ", + content = @Content( + mediaType = "application/json", + schema = @Schema( + name = "TokenResponseDTO", + implementation = TokenDTO.ResponseDTO.class + )) + ) + @PostMapping("/token-reissue") + public ResponseEntity reissue(HttpServletRequest request, HttpServletResponse response){ + + // ์ฟ ํ‚ค์—์„œ refresh ํ† ํฐ ์–ป๊ธฐ + String refresh = null; + Cookie[] cookies = request.getCookies(); + for(Cookie cookie : cookies){ + if(cookie.getName().equals("refresh")){ + refresh = cookie.getValue(); + } + } + + TokenDTO tokenDTO = tokenService.reissueAccessToken(refresh); + + // Refresh ํ† ํฐ์€ ์ฟ ํ‚ค๋กœ ์ „๋‹ฌ + response.addCookie(CookieUtil.createCookie("refresh", tokenDTO.getRefreshToken())); + + // Access ํ† ํฐ, code, isNewUser๋Š” body๋กœ ์ „๋‹ฌ + TokenDTO.ResponseDTO dto = TokenDTO.ResponseDTO.builder() + .accessToken(tokenDTO.getAccessToken()) + .code(tokenDTO.getCode()) + .isNewUser(tokenDTO.isNewUser()) + .build(); + + return ResponseUtil.buildResponse(200, "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์™„๋ฃŒ", dto); + } + +} diff --git a/src/main/java/org/example/studylog/dto/.gitkeep b/src/main/java/org/example/studylog/dto/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/dto/BackgroundDTO.java b/src/main/java/org/example/studylog/dto/BackgroundDTO.java new file mode 100644 index 0000000..14257b1 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/BackgroundDTO.java @@ -0,0 +1,26 @@ +package org.example.studylog.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +public class BackgroundDTO { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class RequestDTO { + @NotNull(message = "๋ฐฐ๊ฒฝํ™”๋ฉด ์ด๋ฏธ์ง€๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + private MultipartFile coverImage; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class ResponseDTO { + private String coverImage; + } + +} diff --git a/src/main/java/org/example/studylog/dto/CategoryDTO.java b/src/main/java/org/example/studylog/dto/CategoryDTO.java new file mode 100644 index 0000000..e72ac4e --- /dev/null +++ b/src/main/java/org/example/studylog/dto/CategoryDTO.java @@ -0,0 +1,25 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.entity.category.Category; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CategoryDTO { + private Long id; + private String name; + private String color; + + public static CategoryDTO from(Category category){ + return CategoryDTO.builder() + .id(category.getId()) + .name(category.getName()) + .color(String.valueOf(category.getColor())) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/dto/MainPageResponseDTO.java b/src/main/java/org/example/studylog/dto/MainPageResponseDTO.java new file mode 100644 index 0000000..36e0395 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/MainPageResponseDTO.java @@ -0,0 +1,55 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.dto.friend.FriendResponseDTO; + +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MainPageResponseDTO { + + private List following; + private ProfileDTO profile; + private StreakDTO streak; + private List categories; + private Boolean isFollowing; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ProfileDTO { + private Long userId; + private String coverImage; + private String profileImage; + private String name; + private String intro; + private Integer level; + private String code; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StreakDTO { + private Integer maxStreak; + private Map recordCountPerDay; // currentStreak โ†’ recordCountPerDay๋กœ ๋ณ€๊ฒฝ + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CategoryCountDTO { + private String name; + private Integer count; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/ProfileCreateRequestDTO.java b/src/main/java/org/example/studylog/dto/ProfileCreateRequestDTO.java new file mode 100644 index 0000000..dd3a0a4 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/ProfileCreateRequestDTO.java @@ -0,0 +1,18 @@ +package org.example.studylog.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +public class ProfileCreateRequestDTO { + @NotNull(message = "์‚ฌ์ง„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + private MultipartFile profileImage; + @NotBlank(message = "๋‹‰๋„ค์ž„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + private String nickname; + @NotBlank(message = "ํ•œ์ค„ ์†Œ๊ฐœ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + private String intro; +} diff --git a/src/main/java/org/example/studylog/dto/ProfileResponseDTO.java b/src/main/java/org/example/studylog/dto/ProfileResponseDTO.java new file mode 100644 index 0000000..cfe68b7 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/ProfileResponseDTO.java @@ -0,0 +1,14 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class ProfileResponseDTO { + private String profileImage; + private String nickname; + private String intro; +} diff --git a/src/main/java/org/example/studylog/dto/ProfileUpdateRequestDTO.java b/src/main/java/org/example/studylog/dto/ProfileUpdateRequestDTO.java new file mode 100644 index 0000000..5d28c18 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/ProfileUpdateRequestDTO.java @@ -0,0 +1,13 @@ +package org.example.studylog.dto; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +public class ProfileUpdateRequestDTO { + private MultipartFile profileImage; + private String nickname; + private String intro; +} diff --git a/src/main/java/org/example/studylog/dto/QuizDTO.java b/src/main/java/org/example/studylog/dto/QuizDTO.java new file mode 100644 index 0000000..2c0ad6b --- /dev/null +++ b/src/main/java/org/example/studylog/dto/QuizDTO.java @@ -0,0 +1,17 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class QuizDTO { + private Long id; + private String question; + private String type; // "OX" ๋˜๋Š” "SHORT_ANSWER" + private String level; // "ํ•˜", "์ค‘", "์ƒ" +} diff --git a/src/main/java/org/example/studylog/dto/ResponseDTO.java b/src/main/java/org/example/studylog/dto/ResponseDTO.java new file mode 100644 index 0000000..fa42797 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/ResponseDTO.java @@ -0,0 +1,14 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ResponseDTO { + private int statusCode; + private String message; + private T data; +} diff --git a/src/main/java/org/example/studylog/dto/StreakDTO.java b/src/main/java/org/example/studylog/dto/StreakDTO.java new file mode 100644 index 0000000..bcccfc5 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/StreakDTO.java @@ -0,0 +1,15 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StreakDTO { + private Integer currentStreak; + private Boolean isStreakUpdated; +} diff --git a/src/main/java/org/example/studylog/dto/UserInfoResponseDTO.java b/src/main/java/org/example/studylog/dto/UserInfoResponseDTO.java new file mode 100644 index 0000000..476badd --- /dev/null +++ b/src/main/java/org/example/studylog/dto/UserInfoResponseDTO.java @@ -0,0 +1,18 @@ +package org.example.studylog.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserInfoResponseDTO { + private String profileImage; + private String nickname; + private String intro; + private Long friendCount; + private String code; +} diff --git a/src/main/java/org/example/studylog/dto/category/CategoryResponseDTO.java b/src/main/java/org/example/studylog/dto/category/CategoryResponseDTO.java new file mode 100644 index 0000000..8d140b5 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/category/CategoryResponseDTO.java @@ -0,0 +1,16 @@ +package org.example.studylog.dto.category; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CategoryResponseDTO { + private Long id; + private String name; + private String color; +} diff --git a/src/main/java/org/example/studylog/dto/category/CreateCategoryRequestDTO.java b/src/main/java/org/example/studylog/dto/category/CreateCategoryRequestDTO.java new file mode 100644 index 0000000..f80fc6e --- /dev/null +++ b/src/main/java/org/example/studylog/dto/category/CreateCategoryRequestDTO.java @@ -0,0 +1,21 @@ +package org.example.studylog.dto.category; + +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.entity.category.Color; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Getter +@Setter +public class CreateCategoryRequestDTO { + + @NotBlank(message = "์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Size(max = 10, message = "์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์€ 10์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + private String name; + + @NotNull(message = "์ƒ‰์ƒ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + private Color color; +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/category/UpdateCategoryRequestDTO.java b/src/main/java/org/example/studylog/dto/category/UpdateCategoryRequestDTO.java new file mode 100644 index 0000000..6a56612 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/category/UpdateCategoryRequestDTO.java @@ -0,0 +1,21 @@ +package org.example.studylog.dto.category; + +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.entity.category.Color; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Getter +@Setter +public class UpdateCategoryRequestDTO { + + @NotBlank(message = "์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + @Size(max = 10, message = "์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์€ 10์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + private String name; + + @NotNull(message = "์ƒ‰์ƒ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + private Color color; +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/friend/FriendNameDTO.java b/src/main/java/org/example/studylog/dto/friend/FriendNameDTO.java new file mode 100644 index 0000000..787f9c3 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/friend/FriendNameDTO.java @@ -0,0 +1,12 @@ +package org.example.studylog.dto.friend; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class FriendNameDTO { + private String nickname; +} diff --git a/src/main/java/org/example/studylog/dto/friend/FriendRequestDTO.java b/src/main/java/org/example/studylog/dto/friend/FriendRequestDTO.java new file mode 100644 index 0000000..02eb173 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/friend/FriendRequestDTO.java @@ -0,0 +1,10 @@ +package org.example.studylog.dto.friend; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class FriendRequestDTO { + @NotBlank(message = "์ฝ”๋“œ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + private String code; +} diff --git a/src/main/java/org/example/studylog/dto/friend/FriendResponseDTO.java b/src/main/java/org/example/studylog/dto/friend/FriendResponseDTO.java new file mode 100644 index 0000000..66a59df --- /dev/null +++ b/src/main/java/org/example/studylog/dto/friend/FriendResponseDTO.java @@ -0,0 +1,15 @@ +package org.example.studylog.dto.friend; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class FriendResponseDTO { + private Long id; + private String nickname; + private String profileImage; + private String code; +} diff --git a/src/main/java/org/example/studylog/dto/notification/NotificationDTO.java b/src/main/java/org/example/studylog/dto/notification/NotificationDTO.java new file mode 100644 index 0000000..011b446 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/notification/NotificationDTO.java @@ -0,0 +1,15 @@ +package org.example.studylog.dto.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.example.studylog.entity.notification.NotificationType; + + +@Builder +@AllArgsConstructor +@Getter +public class NotificationDTO { + private NotificationType type; + private String content; +} diff --git a/src/main/java/org/example/studylog/dto/notification/NotificationListResponseDTO.java b/src/main/java/org/example/studylog/dto/notification/NotificationListResponseDTO.java new file mode 100644 index 0000000..535a888 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/notification/NotificationListResponseDTO.java @@ -0,0 +1,26 @@ +package org.example.studylog.dto.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.example.studylog.entity.notification.Notification; +import org.example.studylog.util.TimeUtil; + +@Builder +@AllArgsConstructor +@Getter +public class NotificationListResponseDTO { + private String type; + private String content; + private String timeAgo; + private boolean isRead; + + public static NotificationListResponseDTO from(Notification notification) { + return NotificationListResponseDTO.builder() + .type(notification.getType().getLabel()) + .content(notification.getContent()) + .timeAgo(TimeUtil.formatTimeAgo(notification.getCreatedAt())) + .isRead(notification.isRead()) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/dto/notification/NotificationResponseDTO.java b/src/main/java/org/example/studylog/dto/notification/NotificationResponseDTO.java new file mode 100644 index 0000000..a9a581b --- /dev/null +++ b/src/main/java/org/example/studylog/dto/notification/NotificationResponseDTO.java @@ -0,0 +1,13 @@ +package org.example.studylog.dto.notification; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class NotificationResponseDTO { + private String type; + private String content; +} diff --git a/src/main/java/org/example/studylog/dto/oauth/CustomOAuth2User.java b/src/main/java/org/example/studylog/dto/oauth/CustomOAuth2User.java new file mode 100644 index 0000000..d7182a2 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/CustomOAuth2User.java @@ -0,0 +1,51 @@ +package org.example.studylog.dto.oauth; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +public class CustomOAuth2User implements OAuth2User { + + private final UserDTO userDTO; + + public CustomOAuth2User(UserDTO userDTO) { + this.userDTO = userDTO; + } + + @Override + public Map getAttributes() { + + return null; + } + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + + @Override + public String getAuthority() { + return userDTO.getRole(); + } + }); + + return collection; + } + + @Override + public String getName() { + return userDTO.getOauthId(); + } + + public String getNickname(){ + return userDTO.getNickname(); + } + + public boolean isProfileCompleted(){ + return userDTO.isProfileCompleted(); + } +} diff --git a/src/main/java/org/example/studylog/dto/oauth/GoogleResponse.java b/src/main/java/org/example/studylog/dto/oauth/GoogleResponse.java new file mode 100644 index 0000000..d7c903f --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/GoogleResponse.java @@ -0,0 +1,32 @@ +package org.example.studylog.dto.oauth; + +import java.util.Map; + +public class GoogleResponse implements OAuth2Response { + + private final Map attribute; + + public GoogleResponse(Map attribute){ + this.attribute = attribute; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attribute.get("sub").toString(); + } + + @Override + public String getName() { + return attribute.get("name").toString(); + } + + @Override + public String getProfileImage() { + return attribute.get("picture").toString(); + } +} diff --git a/src/main/java/org/example/studylog/dto/oauth/KakaoResponse.java b/src/main/java/org/example/studylog/dto/oauth/KakaoResponse.java new file mode 100644 index 0000000..a45892d --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/KakaoResponse.java @@ -0,0 +1,36 @@ +package org.example.studylog.dto.oauth; + +import java.util.Map; + +public class KakaoResponse implements OAuth2Response { + + private final Map attribute; + private final Map kakaoAccount; + + public KakaoResponse(Map attribute) { + this.attribute = (Map) attribute; + this.kakaoAccount = (Map) attribute.get("kakao_account"); + } + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getName() { + Map profile = (Map) kakaoAccount.get("profile"); + return profile.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + Map profile = (Map) kakaoAccount.get("profile"); + return profile.get("profile_image_url").toString(); + } +} diff --git a/src/main/java/org/example/studylog/dto/oauth/OAuth2Response.java b/src/main/java/org/example/studylog/dto/oauth/OAuth2Response.java new file mode 100644 index 0000000..012256f --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/OAuth2Response.java @@ -0,0 +1,17 @@ +package org.example.studylog.dto.oauth; + +public interface OAuth2Response { + + // ์ œ๊ณต์ž (Ex. kakao, google...) + String getProvider(); + + // ์ œ๊ณต์ž์—์„œ ๋ฐœ๊ธ‰ํ•ด์ฃผ๋Š” ์•„์ด๋””(๋ฒˆํ˜ธ) + String getProviderId(); + + // ์‚ฌ์šฉ์ž ์ด๋ฆ„ + String getName(); + + // ์‚ฌ์šฉ์ž ํ”„๋กœํ•„์ด๋ฏธ์ง€ + String getProfileImage(); + +} diff --git a/src/main/java/org/example/studylog/dto/oauth/TokenDTO.java b/src/main/java/org/example/studylog/dto/oauth/TokenDTO.java new file mode 100644 index 0000000..49ba2c9 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/TokenDTO.java @@ -0,0 +1,27 @@ +package org.example.studylog.dto.oauth; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +@Schema(name = "TokenDTO") +public class TokenDTO { + + private String refreshToken; + private String accessToken; + private String code; + private boolean isNewUser; + + @Getter + @Builder + @Schema(name = "TokenResponseDTO") + public static class ResponseDTO { + private String accessToken; + private String code; + private boolean isNewUser; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/oauth/UserDTO.java b/src/main/java/org/example/studylog/dto/oauth/UserDTO.java new file mode 100644 index 0000000..a659dc4 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/oauth/UserDTO.java @@ -0,0 +1,15 @@ +package org.example.studylog.dto.oauth; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UserDTO { + + private String role; + private String nickname; + private String oauthId; + private boolean isProfileCompleted; + +} diff --git a/src/main/java/org/example/studylog/dto/quiz/CreateQuizRequestDTO.java b/src/main/java/org/example/studylog/dto/quiz/CreateQuizRequestDTO.java new file mode 100644 index 0000000..4b7188a --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/CreateQuizRequestDTO.java @@ -0,0 +1,21 @@ +package org.example.studylog.dto.quiz; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.entity.quiz.QuizLevel; + +@Getter +@Setter +public class CreateQuizRequestDTO { + + @NotNull(message = "๋‚œ์ด๋„๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + private QuizLevel level; + + @Min(value = 1, message = "ํ€ด์ฆˆ ๊ฐฏ์ˆ˜๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค") + private int quizCount; + + private String requirement; +} diff --git a/src/main/java/org/example/studylog/dto/quiz/QuizListResponseDTO.java b/src/main/java/org/example/studylog/dto/quiz/QuizListResponseDTO.java new file mode 100644 index 0000000..1f43dc1 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/QuizListResponseDTO.java @@ -0,0 +1,19 @@ +package org.example.studylog.dto.quiz; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.dto.CategoryDTO; + +import java.util.List; + +@Getter +@Setter +@Builder +public class QuizListResponseDTO { + + private List categories; + private List quizzes; + private boolean hasNext; + private Long lastId; +} diff --git a/src/main/java/org/example/studylog/dto/quiz/QuizResponseDTO.java b/src/main/java/org/example/studylog/dto/quiz/QuizResponseDTO.java new file mode 100644 index 0000000..695f741 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/QuizResponseDTO.java @@ -0,0 +1,41 @@ +package org.example.studylog.dto.quiz; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.dto.CategoryDTO; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.quiz.Quiz; + +import java.time.format.DateTimeFormatter; + +@Setter +@Getter +@Builder +public class QuizResponseDTO { + + private String createdAt; + private String type; + private CategoryDTO category; + private String question; + private String answer; + private String level; + private Long recordId; + + public static QuizResponseDTO from(Quiz quiz, Category category){ + + return QuizResponseDTO.builder() + .createdAt(quiz.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy.MM.dd"))) + .type(String.valueOf(quiz.getType())) + .category(CategoryDTO.builder() + .id(category.getId()) + .name(category.getName()) + .color(String.valueOf(category.getColor())) + .build()) + .question(quiz.getQuestion()) + .answer(quiz.getAnswer()) + .level(String.valueOf(quiz.getLevel())) + .recordId(quiz.getRecord().getId()) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/dto/quiz/QuizSummaryDTO.java b/src/main/java/org/example/studylog/dto/quiz/QuizSummaryDTO.java new file mode 100644 index 0000000..3860e96 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/QuizSummaryDTO.java @@ -0,0 +1,27 @@ +package org.example.studylog.dto.quiz; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import org.example.studylog.entity.quiz.Quiz; + +@Getter +@Setter +@Builder +public class QuizSummaryDTO { + private Long id; + private String category; + private String color; + private String question; + private String level; + + public static QuizSummaryDTO from(Quiz quiz) { + return QuizSummaryDTO.builder() + .id(quiz.getId()) + .category(quiz.getCategory().getName()) + .color(String.valueOf(quiz.getCategory().getColor())) + .question(quiz.getQuestion()) + .level(String.valueOf(quiz.getLevel())) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptRequest.java b/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptRequest.java new file mode 100644 index 0000000..d159f51 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptRequest.java @@ -0,0 +1,25 @@ +package org.example.studylog.dto.quiz.chatGPT; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatGptRequest { + + private String model = "gpt-3.5-turbo"; + private List messages; + private double temperature = 0.7; + + @Data + @AllArgsConstructor + @NoArgsConstructor + public static class Message { + private String role; // "user" + private String content; + } +} diff --git a/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptResponse.java b/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptResponse.java new file mode 100644 index 0000000..36bf94e --- /dev/null +++ b/src/main/java/org/example/studylog/dto/quiz/chatGPT/ChatGptResponse.java @@ -0,0 +1,25 @@ +package org.example.studylog.dto.quiz.chatGPT; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class ChatGptResponse { + + private List choices; + + @Data + public static class Choice { + private Message message; + } + + @Data + public static class Message { + private String role; + private String content; + } +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordRequestDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordRequestDTO.java new file mode 100644 index 0000000..f7292c5 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordRequestDTO.java @@ -0,0 +1,24 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.Getter; +import lombok.Setter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Getter +@Setter +public class CreateStudyRecordRequestDTO { + + @NotNull(message = "์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + private Long categoryId; // ํŒ€์› ์—”ํ‹ฐํ‹ฐ์—์„œ ์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜ + + @NotBlank(message = "์ œ๋ชฉ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค") + @Size(max = 20, message = "์ œ๋ชฉ์€ 20์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") // ํŒ€์› ์—”ํ‹ฐํ‹ฐ length=20 + private String title; + + @NotBlank(message = "๋‚ด์šฉ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค") + @Size(min = 10, message = "๋‚ด์šฉ์€ ์ตœ์†Œ 10์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”") + private String content; +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordResponseDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordResponseDTO.java new file mode 100644 index 0000000..ded569c --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/CreateStudyRecordResponseDTO.java @@ -0,0 +1,16 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.dto.StreakDTO; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateStudyRecordResponseDTO { + private StudyRecordDTO record; + private StreakDTO streak; +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDTO.java new file mode 100644 index 0000000..d66a4ff --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDTO.java @@ -0,0 +1,20 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.dto.CategoryDTO; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudyRecordDTO { + private Long id; + private String title; + private String content; + private CategoryDTO category; + private String createdAt; // "2025-06-24" ํ˜•์‹ + private Boolean hasQuiz; // API ๋ช…์„ธ์„œ์— ๋งž์ถฐ hasQuiz๋กœ ์œ ์ง€ (๋‚ด๋ถ€๋Š” isQuizCreated) +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailDTO.java new file mode 100644 index 0000000..536f799 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailDTO.java @@ -0,0 +1,20 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.dto.CategoryDTO; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudyRecordDetailDTO { + private Long id; + private String title; + private String content; + private CategoryDTO category; + private String createdAt; // "2025-06-24" + private Integer quizCount; // ํ€ด์ฆˆ ๊ฐœ์ˆ˜ +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailResponseDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailResponseDTO.java new file mode 100644 index 0000000..7f17d9a --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordDetailResponseDTO.java @@ -0,0 +1,19 @@ +package org.example.studylog.dto.studyrecord; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.studylog.dto.QuizDTO; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudyRecordDetailResponseDTO { + private StudyRecordDetailDTO record; + private List quizzes; +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterRequestDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterRequestDTO.java new file mode 100644 index 0000000..44fd1ff --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterRequestDTO.java @@ -0,0 +1,13 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class StudyRecordFilterRequestDTO { + private Long categoryId; // ์„ ํƒ์  + private String date; // "YYYY-MM-DD" ํ˜•์‹, ์„ ํƒ์  + private Long lastId; // ๋ฌดํ•œ ์Šคํฌ๋กค์šฉ, ์„ ํƒ์  + private Integer size = 10; // ๊ธฐ๋ณธ ํŽ˜์ด์ง€ ํฌ๊ธฐ +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterResponseDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterResponseDTO.java new file mode 100644 index 0000000..d5650c1 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordFilterResponseDTO.java @@ -0,0 +1,19 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudyRecordFilterResponseDTO { + private List records; + private Boolean hasMore; // ๋” ๋งŽ์€ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š”์ง€ + private Long nextLastId; // ๋‹ค์Œ ์š”์ฒญ์— ์‚ฌ์šฉํ•  lastId +} + diff --git a/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordListResponseDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordListResponseDTO.java new file mode 100644 index 0000000..7dd4635 --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/StudyRecordListResponseDTO.java @@ -0,0 +1,16 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StudyRecordListResponseDTO { + private List records; +} diff --git a/src/main/java/org/example/studylog/dto/studyrecord/UpdateStudyRecordRequestDTO.java b/src/main/java/org/example/studylog/dto/studyrecord/UpdateStudyRecordRequestDTO.java new file mode 100644 index 0000000..36c519e --- /dev/null +++ b/src/main/java/org/example/studylog/dto/studyrecord/UpdateStudyRecordRequestDTO.java @@ -0,0 +1,24 @@ +package org.example.studylog.dto.studyrecord; + +import lombok.Getter; +import lombok.Setter; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Getter +@Setter +public class UpdateStudyRecordRequestDTO { + + @NotNull(message = "์นดํ…Œ๊ณ ๋ฆฌ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค") + private Long categoryId; + + @NotBlank(message = "์ œ๋ชฉ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค") + @Size(max = 20, message = "์ œ๋ชฉ์€ 20์ž๋ฅผ ์ดˆ๊ณผํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค") + private String title; + + @NotBlank(message = "๋‚ด์šฉ์€ ํ•„์ˆ˜ ์ž…๋ ฅ ํ•ญ๋ชฉ์ž…๋‹ˆ๋‹ค") + @Size(min = 10, message = "๋‚ด์šฉ์€ ์ตœ์†Œ 10์ž ์ด์ƒ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”") + private String content; +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/entity/.gitkeep b/src/main/java/org/example/studylog/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/entity/BaseEntity.java b/src/main/java/org/example/studylog/entity/BaseEntity.java new file mode 100644 index 0000000..3dbce2b --- /dev/null +++ b/src/main/java/org/example/studylog/entity/BaseEntity.java @@ -0,0 +1,28 @@ +package org.example.studylog.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@SuperBuilder +@NoArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createDate; + + @LastModifiedDate + private LocalDateTime updateDate; +} diff --git a/src/main/java/org/example/studylog/entity/Friend.java b/src/main/java/org/example/studylog/entity/Friend.java new file mode 100644 index 0000000..a330d9d --- /dev/null +++ b/src/main/java/org/example/studylog/entity/Friend.java @@ -0,0 +1,27 @@ +package org.example.studylog.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.example.studylog.entity.user.User; + +@Getter +@Setter +@Builder +@Entity +@NoArgsConstructor +@AllArgsConstructor +public class Friend { + + @Id + @GeneratedValue + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "friend_id", nullable = false) + private User friend; + +} diff --git a/src/main/java/org/example/studylog/entity/RefreshEntity.java b/src/main/java/org/example/studylog/entity/RefreshEntity.java new file mode 100644 index 0000000..9b66aba --- /dev/null +++ b/src/main/java/org/example/studylog/entity/RefreshEntity.java @@ -0,0 +1,24 @@ +package org.example.studylog.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.*; + +@Getter +@Setter +@Builder +@Entity +@NoArgsConstructor +@AllArgsConstructor +public class RefreshEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String oauthId; + private String refresh; + private String expiration; +} diff --git a/src/main/java/org/example/studylog/entity/Streak.java b/src/main/java/org/example/studylog/entity/Streak.java new file mode 100644 index 0000000..57fd7b3 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/Streak.java @@ -0,0 +1,35 @@ +package org.example.studylog.entity; + +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; +import org.example.studylog.entity.user.User; + +import java.time.LocalDate; + +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "streak") +public class Streak extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(name = "current_streak", nullable = false) + private Integer currentStreak = 0; + + @Column(name = "max_streak", nullable = false) + private Integer maxStreak = 0; + + @Column(name = "last_record_date") + private LocalDate lastRecordDate; +} diff --git a/src/main/java/org/example/studylog/entity/StudyRecord.java b/src/main/java/org/example/studylog/entity/StudyRecord.java new file mode 100644 index 0000000..32d9939 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/StudyRecord.java @@ -0,0 +1,48 @@ +package org.example.studylog.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.quiz.Quiz; +import org.example.studylog.entity.user.User; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "study_record") +public class StudyRecord extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 20) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(name = "is_quiz_created", nullable = false) + private boolean isQuizCreated = false; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private Category category; + + @OneToMany(mappedBy = "record", cascade = CascadeType.ALL, orphanRemoval = true) + private List quizzes = new ArrayList<>(); +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/entity/category/Category.java b/src/main/java/org/example/studylog/entity/category/Category.java new file mode 100644 index 0000000..cb72743 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/category/Category.java @@ -0,0 +1,43 @@ +package org.example.studylog.entity.category; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.quiz.Quiz; +import org.example.studylog.entity.user.User; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Category { + + @Id + @GeneratedValue + private Long id; + + @Column(nullable = false, length = 10) + private String name; + + @Enumerated(EnumType.STRING) + private Color color; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @OneToMany(mappedBy = "category") + private List records = new ArrayList<>(); + + @OneToMany(mappedBy = "category") + private List quizzes = new ArrayList<>(); +} diff --git a/src/main/java/org/example/studylog/entity/category/Color.java b/src/main/java/org/example/studylog/entity/category/Color.java new file mode 100644 index 0000000..be445af --- /dev/null +++ b/src/main/java/org/example/studylog/entity/category/Color.java @@ -0,0 +1,14 @@ +package org.example.studylog.entity.category; + +public enum Color { + ROSE_PINK, + BABY_BLUE, + BABY_PINK, + COOL_GRAY, + MELON_GREEN, + LAVENDER_PURPLE, + PALE_YELLOW, + LILAC_PURPLE, + CEMENT_GRAY, + PALE_ORANGE +} diff --git a/src/main/java/org/example/studylog/entity/notification/Notification.java b/src/main/java/org/example/studylog/entity/notification/Notification.java new file mode 100644 index 0000000..8668732 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/notification/Notification.java @@ -0,0 +1,37 @@ +package org.example.studylog.entity.notification; + +import jakarta.persistence.*; +import lombok.*; +import org.example.studylog.entity.user.User; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@Entity +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class Notification { + + @Id + @GeneratedValue + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Enumerated(EnumType.STRING) + private NotificationType type; + + private String content; + + @CreatedDate + private LocalDateTime createdAt; + + @Column(nullable = false) + private boolean isRead = false; +} diff --git a/src/main/java/org/example/studylog/entity/notification/NotificationType.java b/src/main/java/org/example/studylog/entity/notification/NotificationType.java new file mode 100644 index 0000000..818bb74 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/notification/NotificationType.java @@ -0,0 +1,17 @@ +package org.example.studylog.entity.notification; + +import lombok.Getter; + +@Getter +public enum NotificationType { + ADD_FRIEND("์นœ๊ตฌ ์ถ”๊ฐ€"), + DELETE_FRIEND("์นœ๊ตฌ ์‚ญ์ œ"), + STREAK("์ŠคํŠธ๋ฆญ"), + BADGE("๋ฑƒ์ง€"); + + private final String label; + + NotificationType(String label){ + this.label = label; + } +} diff --git a/src/main/java/org/example/studylog/entity/quiz/Quiz.java b/src/main/java/org/example/studylog/entity/quiz/Quiz.java new file mode 100644 index 0000000..90a44b5 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/quiz/Quiz.java @@ -0,0 +1,57 @@ +package org.example.studylog.entity.quiz; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.example.studylog.entity.BaseEntity; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.user.User; + +import java.time.LocalDateTime; + +@Getter +@Setter +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Quiz extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String question; + + @Column(nullable = false) + private String answer; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private QuizLevel level; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private QuizType type; + + @Column(nullable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "record_id", nullable = false) + private StudyRecord record; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false) + private Category category; + +} diff --git a/src/main/java/org/example/studylog/entity/quiz/QuizLevel.java b/src/main/java/org/example/studylog/entity/quiz/QuizLevel.java new file mode 100644 index 0000000..6348ad3 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/quiz/QuizLevel.java @@ -0,0 +1,23 @@ +package org.example.studylog.entity.quiz; + +import lombok.Getter; + +@Getter +public enum QuizLevel { + EASY("ํ•˜"), + MEDIUM("์ค‘"), + HARD("์ƒ"); + + private final String label; + + QuizLevel(String label){ + this.label = label; + } + + public static QuizLevel fromLabel(String label){ + for (QuizLevel level : values()){ + if(level.label.equals(label)) return level; + } + throw new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋‚œ์ด๋„์ž…๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/org/example/studylog/entity/quiz/QuizType.java b/src/main/java/org/example/studylog/entity/quiz/QuizType.java new file mode 100644 index 0000000..9370490 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/quiz/QuizType.java @@ -0,0 +1,6 @@ +package org.example.studylog.entity.quiz; + +public enum QuizType { + OX, + SHORT_ANSWER +} diff --git a/src/main/java/org/example/studylog/entity/user/LevelThresholds.java b/src/main/java/org/example/studylog/entity/user/LevelThresholds.java new file mode 100644 index 0000000..479b3d0 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/user/LevelThresholds.java @@ -0,0 +1,35 @@ +package org.example.studylog.entity.user; + +import java.util.LinkedHashMap; +import java.util.Map; + +public class LevelThresholds { + private static final Map thresholds = + new LinkedHashMap<>() {{ + put(1, 10); + put(2, 30); + put(3, 60); + put(4, 100); + put(5, 150); + put(6, 210); + put(7, 280); + put(8, 360); + put(9, 450); + put(10, 550); + }}; + + + public static int getLevelForRecordCount(long recordCount){ + int level = 0; + + for (Map.Entry entry : thresholds.entrySet()) { + if(recordCount >= entry.getValue()){ + level = entry.getKey(); + } else{ + break; + } + } + + return level; + } +} diff --git a/src/main/java/org/example/studylog/entity/user/Role.java b/src/main/java/org/example/studylog/entity/user/Role.java new file mode 100644 index 0000000..0c49c24 --- /dev/null +++ b/src/main/java/org/example/studylog/entity/user/Role.java @@ -0,0 +1,6 @@ +package org.example.studylog.entity.user; + +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/org/example/studylog/entity/user/User.java b/src/main/java/org/example/studylog/entity/user/User.java new file mode 100644 index 0000000..24e9a2b --- /dev/null +++ b/src/main/java/org/example/studylog/entity/user/User.java @@ -0,0 +1,80 @@ +package org.example.studylog.entity.user; + +import jakarta.persistence.*; +import lombok.*; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.quiz.Quiz; +import org.hibernate.annotations.ColumnDefault; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 20, nullable = false) + private String nickname; + + @Column(length = 100) + private String intro; + + @Column(nullable = false, length = 2048) + private String profileImage; + + @Column(length = 2048) + private String backImage; + + @Column(nullable = false) + private int level; + + @Column(nullable = false) + @ColumnDefault("0") + private Long recordCount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @Column(nullable = false) + private boolean isProfileCompleted; + + @Column(nullable = false, unique = true) + private UUID uuid; + + @Column(length = 5, unique = true) + private String code; + + @Column(nullable = false, unique = true) + private String oauthId; + + @OneToMany(mappedBy = "user") + private List records = new ArrayList<>(); + + @OneToMany(mappedBy = "user") + private List quizzes = new ArrayList<>(); + + @OneToMany(mappedBy = "user") + private List categories = new ArrayList<>(); + + // ๊ธฐ๋ก ์ˆ˜ ์ฆ๊ฐ€ + public void incrementRecordCount(){ + this.recordCount++; + } + + // ๊ธฐ๋ก ์ˆ˜ ๊ฐ์†Œ + public void decrementRecordCount(){ + this.recordCount--; + } +} diff --git a/src/main/java/org/example/studylog/event/LevelEvent.java b/src/main/java/org/example/studylog/event/LevelEvent.java new file mode 100644 index 0000000..d7f78aa --- /dev/null +++ b/src/main/java/org/example/studylog/event/LevelEvent.java @@ -0,0 +1,18 @@ +package org.example.studylog.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.example.studylog.entity.user.User; + +@Getter +@AllArgsConstructor +public class LevelEvent { + + private final User user; + private final int newLevel; + private final ActionType action; + + public enum ActionType{ + UP, DOWN + } +} diff --git a/src/main/java/org/example/studylog/event/RecordEvent.java b/src/main/java/org/example/studylog/event/RecordEvent.java new file mode 100644 index 0000000..b32ad9d --- /dev/null +++ b/src/main/java/org/example/studylog/event/RecordEvent.java @@ -0,0 +1,13 @@ +package org.example.studylog.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.example.studylog.entity.user.User; + +@Getter +@AllArgsConstructor +public class RecordEvent { + + private final User user; + +} diff --git a/src/main/java/org/example/studylog/event/listener/LevelChangeListener.java b/src/main/java/org/example/studylog/event/listener/LevelChangeListener.java new file mode 100644 index 0000000..1c74d72 --- /dev/null +++ b/src/main/java/org/example/studylog/event/listener/LevelChangeListener.java @@ -0,0 +1,45 @@ +package org.example.studylog.event.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.entity.user.LevelThresholds; +import org.example.studylog.entity.user.User; +import org.example.studylog.event.LevelEvent; +import org.example.studylog.event.RecordEvent; +import org.example.studylog.repository.UserRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Slf4j +@RequiredArgsConstructor +public class LevelChangeListener { + + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @EventListener + @Transactional + public void handleRecordEvent(RecordEvent event) { + User currentUser = event.getUser(); + long recordCount = currentUser.getRecordCount(); + + log.info("๋ ˆ๋ฒจ ์ฒดํฌ ์‹œ์ž‘: USER = {}, CURRENT_LEVEL = {}, RECORD_COUNT= {}", + currentUser.getOauthId(), currentUser.getLevel(), recordCount); + + int lastLevel = currentUser.getLevel(); + int newLevel = LevelThresholds.getLevelForRecordCount(recordCount); + if(lastLevel != newLevel){ + currentUser.setLevel(newLevel); // ๋ ˆ๋ฒจ ์—…๋ฐ์ดํŠธ + + if(lastLevel > newLevel){ + eventPublisher.publishEvent(new LevelEvent(currentUser, newLevel, LevelEvent.ActionType.DOWN)); + } + else if(lastLevel < newLevel){ + eventPublisher.publishEvent(new LevelEvent(currentUser, newLevel, LevelEvent.ActionType.UP)); + } + } + } +} diff --git a/src/main/java/org/example/studylog/event/listener/NotificationListener.java b/src/main/java/org/example/studylog/event/listener/NotificationListener.java new file mode 100644 index 0000000..3662658 --- /dev/null +++ b/src/main/java/org/example/studylog/event/listener/NotificationListener.java @@ -0,0 +1,45 @@ +package org.example.studylog.event.listener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.notification.NotificationDTO; +import org.example.studylog.entity.notification.NotificationType; +import org.example.studylog.entity.user.User; +import org.example.studylog.event.LevelEvent; +import org.example.studylog.service.NotificationService; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationListener { + + private final NotificationService notificationService; + + @Async + @EventListener + @Transactional + public void handleLevelChange(LevelEvent event){ + User currentUser = event.getUser(); + log.info("๋ ˆ๋ฒจ ๋ณ€๊ฒฝ ์•Œ๋ฆผ ์ „์†ก: USER={}, LEVEL={}", currentUser.getOauthId(), event.getNewLevel()); + + String message = ""; + if(event.getAction() == LevelEvent.ActionType.UP){ + message = String.format("Lv.%d ๋ฑƒ์ง€๋ฅผ ๋‹ฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.", event.getNewLevel()); + } + else if(event.getAction() == LevelEvent.ActionType.DOWN){ + message = String.format("Lv.%d ๋ฑƒ์ง€๋กœ ํ•˜๋ฝํ–ˆ์Šต๋‹ˆ๋‹ค.", event.getNewLevel()); + } + + // ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + notificationService.sendToClient( + currentUser.getOauthId(), + NotificationDTO.builder() + .content(message) + .type(NotificationType.BADGE) + .build()); + } +} diff --git a/src/main/java/org/example/studylog/exception/BusinessException.java b/src/main/java/org/example/studylog/exception/BusinessException.java new file mode 100644 index 0000000..617ae41 --- /dev/null +++ b/src/main/java/org/example/studylog/exception/BusinessException.java @@ -0,0 +1,14 @@ +package org.example.studylog.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException{ + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode){ + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/org/example/studylog/exception/ErrorCode.java b/src/main/java/org/example/studylog/exception/ErrorCode.java new file mode 100644 index 0000000..8092bf8 --- /dev/null +++ b/src/main/java/org/example/studylog/exception/ErrorCode.java @@ -0,0 +1,23 @@ +package org.example.studylog.exception; + +import lombok.Getter; + +@Getter +public enum ErrorCode { + USER_CODE_NOT_FOUND(404,"์ฝ”๋“œ์— ํ•ด๋‹นํ•˜๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."), + ALREADY_FRIEND(400,"์ด๋ฏธ ์นœ๊ตฌ์ž…๋‹ˆ๋‹ค."), + NOT_FRIEND(400, "์นœ๊ตฌ ๊ด€๊ณ„๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."), + FRIEND_NOT_FOUND(404, "ํ•ด๋‹นํ•˜๋Š” ์นœ๊ตฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."), + SELF_LOOKUP_NOT_ALLOWED(400, "์ž๊ธฐ ์ž์‹ ์€ ์กฐํšŒํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + NOTIFICATION_CONNECTION_ERROR(500, "์•Œ๋ฆผ ์„œ๋ฒ„์™€ ์—ฐ๊ฒฐ์ด ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค."), + STUDY_RECORD_NOT_FOUND(404, "๊ธฐ๋ก์ด ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."), + QUIZ_ALREADY_EXISTS(400, "์ด๋ฏธ ํ€ด์ฆˆ๊ฐ€ ์ƒ์„ฑ๋œ ๊ธฐ๋ก์ž…๋‹ˆ๋‹ค."); + + private int status; + private final String message; + + ErrorCode(int status, String message){ + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/org/example/studylog/exception/TokenValidationException.java b/src/main/java/org/example/studylog/exception/TokenValidationException.java new file mode 100644 index 0000000..d0f3d6a --- /dev/null +++ b/src/main/java/org/example/studylog/exception/TokenValidationException.java @@ -0,0 +1,8 @@ +package org.example.studylog.exception; + +public class TokenValidationException extends RuntimeException{ + + public TokenValidationException(String message){ + super(message); + } +} diff --git a/src/main/java/org/example/studylog/exception/UserNotFoundException.java b/src/main/java/org/example/studylog/exception/UserNotFoundException.java new file mode 100644 index 0000000..811d227 --- /dev/null +++ b/src/main/java/org/example/studylog/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package org.example.studylog.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message){ + super(message); + } +} diff --git a/src/main/java/org/example/studylog/exception/handler/GlobalExceptionHandler.java b/src/main/java/org/example/studylog/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..51d2d0b --- /dev/null +++ b/src/main/java/org/example/studylog/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,57 @@ +package org.example.studylog.exception.handler; + +import org.example.studylog.exception.BusinessException; +import org.example.studylog.exception.TokenValidationException; +import org.example.studylog.exception.UserNotFoundException; +import org.example.studylog.util.ResponseUtil; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(TokenValidationException.class) + public ResponseEntity handlerTokenValidationException(TokenValidationException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of( + "statusCode", 400, + "message", e.getMessage() + )); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFoundException(UserNotFoundException e) { + return ResponseUtil.buildResponse(404, e.getMessage(), null); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage(); + return ResponseUtil.buildResponse(400, message, null); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusinessException(BusinessException e){ + return ResponseUtil.buildResponse(e.getErrorCode().getStatus(), e.getErrorCode().getMessage(), null); + } + + // ์ถ”๊ฐ€: body ์—†๋Š” ์š”์ฒญ ์ฒ˜๋ฆฌ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + Map response = new HashMap<>(); + response.put("statusCode", 400); + response.put("message", "์ž˜๋ชป๋œ ์ ‘๊ทผ์ž…๋‹ˆ๋‹ค"); + response.put("data", Map.of("example", "์š”์ฒญ ๋ณธ๋ฌธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค")); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + +} diff --git a/src/main/java/org/example/studylog/jwt/JWTFilter.java b/src/main/java/org/example/studylog/jwt/JWTFilter.java new file mode 100644 index 0000000..929ab19 --- /dev/null +++ b/src/main/java/org/example/studylog/jwt/JWTFilter.java @@ -0,0 +1,121 @@ +package org.example.studylog.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.dto.oauth.UserDTO; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +@Slf4j +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + public JWTFilter(JWTUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getRequestURI(); + + // ์ œ์™ธํ•  URI ๋ฆฌ์ŠคํŠธ + // ๋กœ๊ทธ์ธ ์™„๋ฃŒ ์งํ›„ ์ ‘๊ทผํ•˜๋Š” ํŽ˜์ด์ง€๋“ค, ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ API, ์ •์  ๋ฆฌ์†Œ์Šค ์ œ์™ธ + return path.equals("/signup") // ๋กœ๊ทธ์ธ ์™„๋ฃŒ ํŽ˜์ด์ง€ + || path.equals("/login-success") // (์˜ˆ์‹œ) ๋‹ค๋ฅธ ์™„๋ฃŒ ํŽ˜์ด์ง€ + || path.equals("/login") + || path.equals("/") + || path.equals("/index.html") + || path.startsWith("/static/") + || path.startsWith("/.well-known") + || path.endsWith(".css") + || path.endsWith(".js") + || path.equals("/auth/token-reissue"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String requestURI = request.getRequestURI(); +// if (requestURI.equals("/auth/token-reissue") || requestURI.equals("/")) { +// filterChain.doFilter(request, response); // ํ† ํฐ ๊ฒ€์‚ฌ ์ƒ๋žต +// return; +// } + + // ์š”์ฒญ(request)์—์„œ Authorization ํ—ค๋” ์ฐพ๊ธฐ + String authorization = request.getHeader("Authorization"); + + // Authorization ํ—ค๋” ๊ฒ€์ฆ + if(authorization == null || !authorization.startsWith("Bearer ")){ + log.warn("Authorization ํ—ค๋” ๊ฒ€์ฆ์— ์‹คํŒจ: REQUEST_URI = {}", requestURI); + // ํ•ด๋‹น ํ•„๋” ์ข…๋ฃŒ + filterChain.doFilter(request, response); + // ๋ฉ”์†Œ๋“œ ์ข…๋ฃŒ + return; + } + + String accessToken = authorization.split(" ")[1]; + + //Authorization ๊ฒ€์ฆ + if (accessToken == null) { + log.warn("JWT ํ† ํฐ์ด ์—†์Œ (token null)"); + System.out.println("token null"); + filterChain.doFilter(request, response); + + return; + } + + //ํ† ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ + try{ + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e){ + PrintWriter writer = response.getWriter(); + writer.print("access token expired"); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // access token ์ธ์ง€ ํ™•์ธ + String category = jwtUtil.getCategory(accessToken); + if(!category.equals("access")){ + PrintWriter writer = response.getWriter(); + writer.print("invalid access token"); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + //ํ† ํฐ์—์„œ oauthId role ํš๋“ + String oauthId = jwtUtil.getOauthId(accessToken); + String role = jwtUtil.getRole(accessToken); + + log.info("JWT ์ธ์ฆ ์„ฑ๊ณต: oauthId={}, role={}", oauthId, role); + + //userDTO๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ๊ฐ’ set + UserDTO userDTO = new UserDTO(); + userDTO.setOauthId(oauthId); + userDTO.setRole(role); + + //UserDetails์— ํšŒ์› ์ •๋ณด ๊ฐ์ฒด ๋‹ด๊ธฐ + CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDTO); + + //์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ ์ธ์ฆ ํ† ํฐ ์ƒ์„ฑ + Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities()); + //์„ธ์…˜์— ์‚ฌ์šฉ์ž ๋“ฑ๋ก + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/example/studylog/jwt/JWTUtil.java b/src/main/java/org/example/studylog/jwt/JWTUtil.java new file mode 100644 index 0000000..eb47ab0 --- /dev/null +++ b/src/main/java/org/example/studylog/jwt/JWTUtil.java @@ -0,0 +1,50 @@ +package org.example.studylog.jwt; + +import io.jsonwebtoken.Jwts; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +@Slf4j +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}")String secret){ + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getCategory(String token){ + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class); + } + + public String getOauthId(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("oauthId", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String category, String oauthId, String role, Long expiredMs){ + log.info("์•ก์„ธ์Šค ํ† ํฐ์ด ๋ฐœ๊ธ‰๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + return Jwts.builder() + .claim("category", category) + .claim("oauthId", oauthId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/org/example/studylog/oauth2/CustomFailureHandler.java b/src/main/java/org/example/studylog/oauth2/CustomFailureHandler.java new file mode 100644 index 0000000..4b4525c --- /dev/null +++ b/src/main/java/org/example/studylog/oauth2/CustomFailureHandler.java @@ -0,0 +1,21 @@ +package org.example.studylog.oauth2; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Slf4j +@Component +public class CustomFailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.error("LOGIN FAILED : {}", exception.getMessage()); + super.onAuthenticationFailure(request, response, exception); + } +} diff --git a/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java new file mode 100644 index 0000000..d8b95c8 --- /dev/null +++ b/src/main/java/org/example/studylog/oauth2/CustomSuccessHandler.java @@ -0,0 +1,58 @@ +package org.example.studylog.oauth2; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.service.TokenService; +import org.example.studylog.util.CookieUtil; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; + +@Component +@Slf4j +public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JWTUtil jwtUtil; + private final TokenService tokenService; + + public CustomSuccessHandler(JWTUtil jwtUtil, TokenService tokenService) { + this.jwtUtil = jwtUtil; + this.tokenService = tokenService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + //OAuth2User + CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); + + String oauthId = customUserDetails.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + // ํ† ํฐ ์ƒ์„ฑ + String refresh = jwtUtil.createJwt("refresh", oauthId, role, 86400000L); + + // refresh ํ† ํฐ ์ €์žฅ + tokenService.addRefreshEntity(oauthId, refresh, 86400000L); + + response.addCookie(CookieUtil.createCookie("refresh", refresh)); + + // ํšŒ์›๊ฐ€์ž… ํ™”๋ฉด์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰์…˜(์ž„์‹œ: ํ”„๋ก ํŠธ ๋กœ๊ทธ์ธ ์™„๋ฃŒ ํ™”๋ฉด์œผ๋กœ ๋ณ€๊ฒฝ ์˜ˆ์ •) + response.sendRedirect("http://localhost:8080/signup"); + + } + +} diff --git a/src/main/java/org/example/studylog/oauth2/ProfileCheckFilter.java b/src/main/java/org/example/studylog/oauth2/ProfileCheckFilter.java new file mode 100644 index 0000000..429b180 --- /dev/null +++ b/src/main/java/org/example/studylog/oauth2/ProfileCheckFilter.java @@ -0,0 +1,52 @@ +package org.example.studylog.oauth2; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.CustomOAuth2User; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +public class ProfileCheckFilter extends OncePerRequestFilter { + + private final UserRepository userRepository; + + public ProfileCheckFilter(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if(auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)){ + String oauthId = auth.getName(); + User user = userRepository.findByOauthId(oauthId); + + // ์‚ฌ์šฉ์ž์˜ ํšŒ์› ์ •๋ณด ์ž…๋ ฅ ์œ ๋ฌด + boolean isProfileCompleted = user.isProfileCompleted(); + String requestURI = request.getRequestURI(); + String method = request.getMethod(); + + // /signup, /users/profile์˜ PUT ์š”์ฒญ์€ ํ—ˆ์šฉ, ๊ทธ ์™ธ๋Š” ๋ง‰์Œ + if(!isProfileCompleted && !requestURI.startsWith("/signup")&& + !(requestURI.equals("/users/profile") && method.equalsIgnoreCase("POST"))) { + log.info("ProfileCheckFilter๋กœ ์ธํ•ด /signup์œผ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰์…˜"); + response.sendRedirect("/signup"); + return; + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/org/example/studylog/repository/.gitkeep b/src/main/java/org/example/studylog/repository/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/repository/CategoryRepository.java b/src/main/java/org/example/studylog/repository/CategoryRepository.java new file mode 100644 index 0000000..762cf3c --- /dev/null +++ b/src/main/java/org/example/studylog/repository/CategoryRepository.java @@ -0,0 +1,20 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CategoryRepository extends JpaRepository { + + // ์‚ฌ์šฉ์ž์˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ด๋ฆ„์ˆœ์œผ๋กœ ์กฐํšŒ + List findByUserOrderByNameAsc(User user); + + // ํŠน์ • ID์™€ ์‚ฌ์šฉ์ž๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ (๊ถŒํ•œ ํ™•์ธ์šฉ) + Optional findByIdAndUser(Long id, User user); + + // ์‚ฌ์šฉ์ž๊ฐ€ ๋™์ผํ•œ ์ด๋ฆ„์˜ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ๊ฐ€์ง€๊ณ  ์žˆ๋Š”์ง€ ํ™•์ธ + boolean existsByNameAndUser(String name, User user); +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/repository/EmitterRepository.java b/src/main/java/org/example/studylog/repository/EmitterRepository.java new file mode 100644 index 0000000..9cccbaa --- /dev/null +++ b/src/main/java/org/example/studylog/repository/EmitterRepository.java @@ -0,0 +1,31 @@ +package org.example.studylog.repository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Repository +public class EmitterRepository { + private Map emitterMap = new ConcurrentHashMap<>(); + + public SseEmitter save(String oauthId, SseEmitter sseEmitter){ + emitterMap.put(oauthId, sseEmitter); + log.info("Saved SseEmitter for {}", sseEmitter); + return sseEmitter; + } + + public SseEmitter get(String oauthId){ + log.info("Got SseEmitter for {}", oauthId); + return emitterMap.get(oauthId); + } + + public void delete(String oauthId) { + emitterMap.remove(oauthId); + log.info("Deleted SseEmitter for {}", oauthId); + } +} diff --git a/src/main/java/org/example/studylog/repository/FriendRepository.java b/src/main/java/org/example/studylog/repository/FriendRepository.java new file mode 100644 index 0000000..1e2763f --- /dev/null +++ b/src/main/java/org/example/studylog/repository/FriendRepository.java @@ -0,0 +1,17 @@ +package org.example.studylog.repository; + + +import org.example.studylog.entity.Friend; +import org.example.studylog.entity.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FriendRepository extends JpaRepository { + + boolean existsByUserAndFriend(User user, User friend); + + Optional findByUserAndFriend(User user, User friend); + + long countByUser(User user); +} diff --git a/src/main/java/org/example/studylog/repository/NotificationRepository.java b/src/main/java/org/example/studylog/repository/NotificationRepository.java new file mode 100644 index 0000000..b24f1a3 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/NotificationRepository.java @@ -0,0 +1,13 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.notification.Notification; +import org.example.studylog.entity.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NotificationRepository extends JpaRepository { + + List findTop30ByUserOrderByCreatedAtDesc(User user); + +} diff --git a/src/main/java/org/example/studylog/repository/QuizRepository.java b/src/main/java/org/example/studylog/repository/QuizRepository.java new file mode 100644 index 0000000..4875d97 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/QuizRepository.java @@ -0,0 +1,9 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.quiz.Quiz; +import org.example.studylog.repository.custom.QuizRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + + +public interface QuizRepository extends JpaRepository, QuizRepositoryCustom { +} diff --git a/src/main/java/org/example/studylog/repository/RefreshRepository.java b/src/main/java/org/example/studylog/repository/RefreshRepository.java new file mode 100644 index 0000000..6544bbb --- /dev/null +++ b/src/main/java/org/example/studylog/repository/RefreshRepository.java @@ -0,0 +1,13 @@ +package org.example.studylog.repository; + +import jakarta.transaction.Transactional; +import org.example.studylog.entity.RefreshEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshRepository extends JpaRepository { + + Boolean existsByRefresh(String refresh); + + @Transactional + void deleteByRefresh(String refresh); +} diff --git a/src/main/java/org/example/studylog/repository/StreakRepository.java b/src/main/java/org/example/studylog/repository/StreakRepository.java new file mode 100644 index 0000000..3621e95 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/StreakRepository.java @@ -0,0 +1,13 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.Streak; +import org.example.studylog.entity.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StreakRepository extends JpaRepository { + + // ์‚ฌ์šฉ์ž์˜ ์ŠคํŠธ๋ฆญ ์ •๋ณด ์กฐํšŒ + Optional findByUser(User user); +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/repository/StudyRecordRepository.java b/src/main/java/org/example/studylog/repository/StudyRecordRepository.java new file mode 100644 index 0000000..e7efd47 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/StudyRecordRepository.java @@ -0,0 +1,100 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.user.User; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface StudyRecordRepository extends JpaRepository { + + // ์‚ฌ์šฉ์ž์˜ ๊ธฐ๋ก์„ ์ตœ์‹ ์ˆœ์œผ๋กœ ์กฐํšŒ + List findByUserOrderByCreateDateDesc(User user); + + // ์ œ๋ชฉ์œผ๋กœ ๊ธฐ๋ก ๊ฒ€์ƒ‰ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + + "WHERE sr.user = :user AND LOWER(sr.title) LIKE LOWER(CONCAT('%', :title, '%')) " + + "ORDER BY sr.createDate DESC") + List findByUserAndTitleContainingIgnoreCaseOrderByCreateDateDesc( + @Param("user") User user, + @Param("title") String title); + + // ๊ธฐ๋ก ์ƒ์„ธ ์กฐํšŒ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + + "LEFT JOIN FETCH sr.quizzes " + + "WHERE sr.id = :recordId") + Optional findByIdWithCategoryAndQuizzes(@Param("recordId") Long recordId); + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ธฐ๋ก ์กฐํšŒ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + + "WHERE sr.user = :user AND sr.category = :category " + + "AND (:lastId IS NULL OR sr.id < :lastId) " + + "ORDER BY sr.id DESC") + List findByUserAndCategoryWithPagination( + @Param("user") User user, + @Param("category") Category category, + @Param("lastId") Long lastId, + Pageable pageable); + + // ๋‚ ์งœ๋ณ„ ๊ธฐ๋ก ์กฐํšŒ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + + "WHERE sr.user = :user " + + "AND DATE(sr.createDate) = :date " + + "AND (:lastId IS NULL OR sr.id < :lastId) " + + "ORDER BY sr.id DESC") + List findByUserAndDateWithPagination( + @Param("user") User user, + @Param("date") LocalDate date, + @Param("lastId") Long lastId, + Pageable pageable); + + // ์นดํ…Œ๊ณ ๋ฆฌ + ๋‚ ์งœ๋ณ„ ๊ธฐ๋ก ์กฐํšŒ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + + "WHERE sr.user = :user " + + "AND sr.category = :category " + + "AND DATE(sr.createDate) = :date " + + "AND (:lastId IS NULL OR sr.id < :lastId) " + + "ORDER BY sr.id DESC") + List findByUserAndCategoryAndDateWithPagination( + @Param("user") User user, + @Param("category") Category category, + @Param("date") LocalDate date, + @Param("lastId") Long lastId, + Pageable pageable); + + // ์ „์ฒด ๊ธฐ๋ก ์กฐํšŒ (N+1 ๋ฐฉ์ง€: JOIN FETCH ์ ์šฉ) + @Query("SELECT sr FROM StudyRecord sr " + + "LEFT JOIN FETCH sr.category " + // โ† N+1 ๋ฐฉ์ง€ + "WHERE sr.user = :user " + + "AND (:lastId IS NULL OR sr.id < :lastId) " + // โ† ๋ฌดํ•œ ์Šคํฌ๋กค + "ORDER BY sr.id DESC") + List findByUserWithPagination( + @Param("user") User user, + @Param("lastId") Long lastId, + Pageable pageable); + + // ํŠน์ • ๋‚ ์งœ์— ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๊ธฐ๋ก ๊ฐœ์ˆ˜ ์กฐํšŒ (์ŠคํŠธ๋ฆญ ๊ณ„์‚ฐ์šฉ) + @Query("SELECT COUNT(sr) FROM StudyRecord sr WHERE sr.user = :user AND DATE(sr.createDate) = :date") + Long countByUserAndCreateDateDate(@Param("user") User user, @Param("date") LocalDate date); + + // ํŠน์ • ๋‚ ์งœ์— ์‚ฌ์šฉ์ž๊ฐ€ ์ž‘์„ฑํ•œ ๊ธฐ๋ก๋“ค ์กฐํšŒ + @Query("SELECT sr FROM StudyRecord sr WHERE sr.user = :user AND DATE(sr.createDate) = :date") + List findByUserAndCreateDateDate(@Param("user") User user, @Param("date") LocalDate date); + + // โœ… ์ƒˆ๋กœ ์ถ”๊ฐ€: ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ธฐ๋ก ๊ฐœ์ˆ˜ ์กฐํšŒ (N+1 ์ฟผ๋ฆฌ ํ•ด๊ฒฐ์šฉ) + @Query("SELECT sr.category.id, COUNT(sr) FROM StudyRecord sr " + + "WHERE sr.user = :user " + + "GROUP BY sr.category.id") + List findCategoryCountsByUser(@Param("user") User user); +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/repository/UserRepository.java b/src/main/java/org/example/studylog/repository/UserRepository.java new file mode 100644 index 0000000..1caa53b --- /dev/null +++ b/src/main/java/org/example/studylog/repository/UserRepository.java @@ -0,0 +1,17 @@ +package org.example.studylog.repository; + +import org.example.studylog.entity.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + User findByOauthId(String oauthId); + + boolean existsByCode(String code); + + Optional findByCode(String code); + + Optional findById(Long id); +} diff --git a/src/main/java/org/example/studylog/repository/custom/FriendRepositoryCustom.java b/src/main/java/org/example/studylog/repository/custom/FriendRepositoryCustom.java new file mode 100644 index 0000000..a794b7f --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/FriendRepositoryCustom.java @@ -0,0 +1,11 @@ +package org.example.studylog.repository.custom; + +import org.example.studylog.dto.friend.FriendResponseDTO; +import org.example.studylog.entity.user.User; + +import java.util.List; + +public interface FriendRepositoryCustom { + List findFriendListByUser(User user); + List findFriendListByNickname(User user, String query); +} diff --git a/src/main/java/org/example/studylog/repository/custom/FriendRepositoryImpl.java b/src/main/java/org/example/studylog/repository/custom/FriendRepositoryImpl.java new file mode 100644 index 0000000..4f39b5d --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/FriendRepositoryImpl.java @@ -0,0 +1,50 @@ +package org.example.studylog.repository.custom; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.friend.FriendResponseDTO; +import org.example.studylog.entity.QFriend; +import org.example.studylog.entity.user.QUser; +import org.example.studylog.entity.user.User; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class FriendRepositoryImpl implements FriendRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + @Override + public List findFriendListByUser(User user) { + QFriend friend = QFriend.friend1; + + return queryFactory + .select(Projections.constructor(FriendResponseDTO.class, + friend.friend.id, + friend.friend.nickname, + friend.friend.profileImage, + friend.friend.code)) + .from(friend) + .where(friend.user.eq(user)) + .fetch(); + } + + @Override + public List findFriendListByNickname(User user, String query) { + QFriend friend = QFriend.friend1; + + return queryFactory + .select(Projections.constructor(FriendResponseDTO.class, + friend.friend.id, + friend.friend.nickname, + friend.friend.profileImage, + friend.friend.code)) + .from(friend) + .where(friend.user.eq(user), + friend.friend.nickname.containsIgnoreCase(query)) + .fetch(); + } +} diff --git a/src/main/java/org/example/studylog/repository/custom/QuizRepositoryCustom.java b/src/main/java/org/example/studylog/repository/custom/QuizRepositoryCustom.java new file mode 100644 index 0000000..499f7d5 --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/QuizRepositoryCustom.java @@ -0,0 +1,10 @@ +package org.example.studylog.repository.custom; + +import org.example.studylog.entity.quiz.Quiz; + +import java.time.LocalDate; +import java.util.List; + +public interface QuizRepositoryCustom { + List findQuizzes(Long userId, Long lastId, int size, LocalDate date, Long categoryId, String query); +} diff --git a/src/main/java/org/example/studylog/repository/custom/QuizRepositoryImpl.java b/src/main/java/org/example/studylog/repository/custom/QuizRepositoryImpl.java new file mode 100644 index 0000000..f033eca --- /dev/null +++ b/src/main/java/org/example/studylog/repository/custom/QuizRepositoryImpl.java @@ -0,0 +1,54 @@ +package org.example.studylog.repository.custom; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.example.studylog.entity.category.QCategory; +import org.example.studylog.entity.quiz.QQuiz; +import org.example.studylog.entity.quiz.Quiz; + +import java.time.LocalDate; +import java.util.List; + +@RequiredArgsConstructor +public class QuizRepositoryImpl implements QuizRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findQuizzes(Long userId, Long lastId, int size, LocalDate date, Long categoryId, String query) { + + QQuiz quiz = QQuiz.quiz; + + BooleanBuilder builder = new BooleanBuilder(); + builder.and(quiz.user.id.eq(userId)); + + if (lastId != null){ + builder.and(quiz.id.lt(lastId)); + } + + if (date != null){ + builder.and(quiz.createdAt.between( + date.atStartOfDay(), + date.plusDays(1).atStartOfDay() + )); + } + + if (categoryId != null){ + builder.and(quiz.category.id.eq(categoryId)); + } + + if (query != null && !query.isBlank()) { + builder.and( + quiz.question.containsIgnoreCase(query)); + } + + return queryFactory.selectFrom(quiz) + .join(quiz.category, QCategory.category).fetchJoin() + .where(builder) + .orderBy(quiz.id.desc()) + .limit(size) + .fetch(); + } + +} diff --git a/src/main/java/org/example/studylog/service/.gitkeep b/src/main/java/org/example/studylog/service/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/example/studylog/service/AwsS3Service.java b/src/main/java/org/example/studylog/service/AwsS3Service.java new file mode 100644 index 0000000..7c18a71 --- /dev/null +++ b/src/main/java/org/example/studylog/service/AwsS3Service.java @@ -0,0 +1,77 @@ +package org.example.studylog.service; + +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.entity.user.User; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AwsS3Service { + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + private final S3Template s3Template; + + public String uploadProfileImage(MultipartFile file, User user){ + // ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธํ•œ ์œ ์ €๋ฉด ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ URL์—์„œ ๊ฐ์ฒด ํ‚ค๊ฐ’ ์ถ”์ถœ ํ›„ ์‚ญ์ œ + if(user.isProfileCompleted()){ + deleteFileByKey(user.getProfileImage()); + } + + return uploadToS3(file); + } + + public String uploadBackImage(MultipartFile file, User user){ + // user์˜ backImage๊ฐ€ null์ด ์•„๋‹ˆ๋ฉด ๋ฒ„ํ‚ท์— ์žˆ๋Š” ๊ธฐ์กด ์ด๋ฏธ์ง€ ์‚ญ์ œ + if(user.getBackImage() != null){ + deleteFileByKey(user.getBackImage()); + } + + return uploadToS3(file); + } + + private String uploadToS3(MultipartFile file){ + try (InputStream stream = file.getInputStream()){ + // ํŒŒ์ผ ์ด๋ฆ„ ๊ณ ์œ ํ•˜๊ฒŒ ํ•˜๊ธฐ ์œ„ํ•ด UUID ์‚ฌ์šฉ + String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename(); + + // ํŒŒ์ผ์˜ ๋ถ€๊ฐ€ ์ •๋ณด ๋‹ด๋Š” ๊ฐ์ฒด (ํŒŒ์ผ ํƒ€์ž…) + ObjectMetadata metadata = ObjectMetadata.builder().contentType(file.getContentType()).build(); + + // ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ + S3Resource s3resource = s3Template.upload(bucket, fileName, stream, metadata); + + return String.valueOf(s3resource.getURL()); + + } catch (Exception e){ + throw new RuntimeException("S3 ์—…๋กœ๋“œ ์‹คํŒจ", e); + } + } + + public void deleteFileByKey(String fileUrl){ + if(fileUrl == null || fileUrl.isEmpty()) return; + String key = getObjectKey(fileUrl); + s3Template.deleteObject(bucket, key); + } + + public String getObjectKey(String URL){ + // URL ์—์„œ ๊ฐ์ฒด ํ‚ค๊ฐ’ ์ถ”์ถœ + String bucketUrl = "https://study-log-1.s3.ap-northeast-2.amazonaws.com/"; + String fileKey = URLDecoder.decode(URL.replace(bucketUrl, ""), StandardCharsets.UTF_8); + return fileKey; + } + +} diff --git a/src/main/java/org/example/studylog/service/CategoryService.java b/src/main/java/org/example/studylog/service/CategoryService.java new file mode 100644 index 0000000..35262c6 --- /dev/null +++ b/src/main/java/org/example/studylog/service/CategoryService.java @@ -0,0 +1,93 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.category.CreateCategoryRequestDTO; +import org.example.studylog.dto.category.UpdateCategoryRequestDTO; +import org.example.studylog.dto.category.CategoryResponseDTO; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.CategoryRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CategoryService { + + private final CategoryRepository categoryRepository; + + @Transactional + public CategoryResponseDTO createCategory(User user, CreateCategoryRequestDTO requestDTO) { + log.info("์‚ฌ์šฉ์ž {}์˜ ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์‹œ์ž‘: {}", user.getOauthId(), requestDTO.getName()); + + // ์ค‘๋ณต ์ด๋ฆ„ ํ™•์ธ + if (categoryRepository.existsByNameAndUser(requestDTO.getName(), user)) { + throw new IllegalArgumentException("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค"); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ + Category category = Category.builder() + .user(user) + .name(requestDTO.getName()) + .color(requestDTO.getColor()) + .build(); + + Category savedCategory = categoryRepository.save(category); + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ƒ์„ฑ ์™„๋ฃŒ: ID={}, ์ด๋ฆ„={}", savedCategory.getId(), savedCategory.getName()); + + return convertToCategoryResponseDTO(savedCategory); + } + + @Transactional + public CategoryResponseDTO updateCategory(User user, Long categoryId, UpdateCategoryRequestDTO requestDTO) { + log.info("์‚ฌ์šฉ์ž {}์˜ ์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์‹œ์ž‘: categoryId={}, ์ƒˆ์ด๋ฆ„={}", + user.getOauthId(), categoryId, requestDTO.getName()); + + // ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + Category category = categoryRepository.findByIdAndUser(categoryId, user) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์นดํ…Œ๊ณ ๋ฆฌ์ด๊ฑฐ๋‚˜ ์ˆ˜์ • ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค")); + + // ์ด๋ฆ„์ด ๋ณ€๊ฒฝ๋˜๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ค‘๋ณต ์ฒดํฌ + if (!category.getName().equals(requestDTO.getName())) { + if (categoryRepository.existsByNameAndUser(requestDTO.getName(), user)) { + throw new IllegalArgumentException("์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์นดํ…Œ๊ณ ๋ฆฌ ์ด๋ฆ„์ž…๋‹ˆ๋‹ค"); + } + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • + category.setName(requestDTO.getName()); + category.setColor(requestDTO.getColor()); + + Category updatedCategory = categoryRepository.save(category); + log.info("์นดํ…Œ๊ณ ๋ฆฌ ์ˆ˜์ • ์™„๋ฃŒ: ID={}, ์ƒˆ์ด๋ฆ„={}", updatedCategory.getId(), updatedCategory.getName()); + + return convertToCategoryResponseDTO(updatedCategory); + } + + @Transactional(readOnly = true) + public List getUserCategories(User user) { + log.info("์‚ฌ์šฉ์ž {}์˜ ์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก ์กฐํšŒ", user.getOauthId()); + + List categories = categoryRepository.findByUserOrderByNameAsc(user); + + return categories.stream() + .map(this::convertToCategoryResponseDTO) + .collect(Collectors.toList()); + } + + private CategoryResponseDTO convertToCategoryResponseDTO(Category category) { + // Color enum์„ ์†Œ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ + String colorValue = category.getColor().name().toLowerCase(); + + return CategoryResponseDTO.builder() + .id(category.getId()) + .name(category.getName()) + .color(colorValue) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/service/FriendService.java b/src/main/java/org/example/studylog/service/FriendService.java new file mode 100644 index 0000000..3bce6ad --- /dev/null +++ b/src/main/java/org/example/studylog/service/FriendService.java @@ -0,0 +1,127 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.notification.NotificationDTO; +import org.example.studylog.dto.friend.FriendNameDTO; +import org.example.studylog.dto.friend.FriendRequestDTO; +import org.example.studylog.dto.friend.FriendResponseDTO; +import org.example.studylog.entity.Friend; +import org.example.studylog.entity.notification.NotificationType; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.BusinessException; +import org.example.studylog.exception.ErrorCode; +import org.example.studylog.repository.FriendRepository; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.repository.custom.FriendRepositoryImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class FriendService { + + private final UserRepository userRepository; + private final FriendRepository friendRepository; + private final FriendRepositoryImpl friendRepositoryImpl; + private final NotificationService notificationService; + + @Transactional(readOnly = true) + public FriendNameDTO findUserByCode(String oauthId, String code) { + // ์ฝ”๋“œ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + User findUser = userRepository.findByCode(code) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_CODE_NOT_FOUND)); + + if(findUser.getOauthId().equals(oauthId)){ + throw new BusinessException(ErrorCode.SELF_LOOKUP_NOT_ALLOWED); + } + + return FriendNameDTO.builder() + .nickname(findUser.getNickname()) + .build(); + } + + @Transactional(readOnly = true) + public List getFriendList(String oauthId) { + // ๋กœ๊ทธ์ธํ•œ ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + return friendRepositoryImpl.findFriendListByUser(user); + } + + @Transactional(readOnly = true) + public List getFriendByQuery(String oauthId, String query) { + // ๋กœ๊ทธ์ธํ•œ ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + return friendRepositoryImpl.findFriendListByNickname(user, query); + } + + @Transactional + public void addFriend(FriendRequestDTO request, String oauthId) { + // ๋กœ๊ทธ์ธํ•œ ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + // ์นœ๊ตฌํ•  ์œ ์ € ์ฐพ๊ธฐ + User friend = userRepository.findByCode(request.getCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_CODE_NOT_FOUND)); + + // ์ด๋ฏธ ์นœ๊ตฌ์ธ์ง€ ํ™•์ธ + boolean alreadyAdded = friendRepository.existsByUserAndFriend(user, friend); + if(alreadyAdded){ + throw new BusinessException(ErrorCode.ALREADY_FRIEND); + } + + // ๋‹จ๋ฐฉํ–ฅ ๊ด€๊ณ„ 2๊ฐœ ์„ค์ • (์–‘๋ฐฉํ–ฅ) + Friend toFriend = Friend.builder() + .user(user) + .friend(friend) + .build(); + Friend fromFriend = Friend.builder() + .user(friend) + .friend(user) + .build(); + + friendRepository.save(toFriend); + friendRepository.save(fromFriend); + + // ์ƒ๋Œ€์—๊ฒŒ ์นœ๊ตฌ ์ถ”๊ฐ€ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + notificationService.sendToClient(friend.getOauthId(), + NotificationDTO.builder() + .content(user.getNickname() + "๋‹˜์ด ์นœ๊ตฌ ์ถ”๊ฐ€๋ฅผ ํ•˜์…จ์Šต๋‹ˆ๋‹ค.") + .type(NotificationType.ADD_FRIEND) + .build()); + } + + @Transactional + public FriendResponseDTO deleteFriend(String oauthId, Long friendId) { + // ๋กœ๊ทธ์ธํ•œ ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + // ์‚ญ์ œํ•  ์นœ๊ตฌ ์ฐพ๊ธฐ + User friend = userRepository.findById(friendId) + .orElseThrow(() -> new BusinessException(ErrorCode.FRIEND_NOT_FOUND)); + + // ์นœ๊ตฌ ๊ด€๊ณ„๊ฐ€ ๋งž๋Š”์ง€ ํ™•์ธ + boolean alreadyAdded = friendRepository.existsByUserAndFriend(user, friend); + if(!alreadyAdded){ + throw new BusinessException(ErrorCode.NOT_FRIEND); + } + + Friend toFriend = friendRepository.findByUserAndFriend(user, friend) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FRIEND)); + Friend fromFriend = friendRepository.findByUserAndFriend(friend, user) + .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FRIEND)); + + friendRepository.delete(toFriend); + friendRepository.delete(fromFriend); + + // ์ƒ๋Œ€์—๊ฒŒ ์นœ๊ตฌ ์‚ญ์ œ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + notificationService.sendToClient(friend.getOauthId(), + NotificationDTO.builder() + .content(user.getNickname() + "๋‹˜์ด ์นœ๊ตฌ ์‚ญ์ œ๋ฅผ ํ•˜์…จ์Šต๋‹ˆ๋‹ค.") + .type(NotificationType.DELETE_FRIEND) + .build()); + + return new FriendResponseDTO(friend.getId(), friend.getNickname(), friend.getProfileImage(), friend.getCode()); + } +} diff --git a/src/main/java/org/example/studylog/service/MainService.java b/src/main/java/org/example/studylog/service/MainService.java new file mode 100644 index 0000000..f56cf61 --- /dev/null +++ b/src/main/java/org/example/studylog/service/MainService.java @@ -0,0 +1,196 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.MainPageResponseDTO; +import org.example.studylog.dto.friend.FriendResponseDTO; +import org.example.studylog.entity.Streak; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.CategoryRepository; +import org.example.studylog.repository.StreakRepository; +import org.example.studylog.repository.StudyRecordRepository; +import org.example.studylog.repository.FriendRepository; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.repository.custom.FriendRepositoryImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MainService { + + private final FriendRepositoryImpl friendRepositoryImpl; + private final FriendRepository friendRepository; + private final StudyRecordRepository studyRecordRepository; + private final StreakRepository streakRepository; + private final CategoryRepository categoryRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public MainPageResponseDTO getMainPageData(User user) { + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹œ์ž‘: ์‚ฌ์šฉ์ž={}", user.getOauthId()); + + // 1. ์นœ๊ตฌ ๋ชฉ๋ก ์กฐํšŒ + List following = friendRepositoryImpl.findFriendListByUser(user); + + // 2. ํ”„๋กœํ•„ ์ •๋ณด ์ƒ์„ฑ + MainPageResponseDTO.ProfileDTO profile = MainPageResponseDTO.ProfileDTO.builder() + .userId(user.getId()) + .coverImage(user.getBackImage()) // null์ด๋ฉด null ๋ฐ˜ํ™˜ + .profileImage(user.getProfileImage()) + .name(user.getNickname()) + .intro(user.getIntro()) + .level(user.getLevel()) + .code(user.getCode()) + .build(); + + // 3. ์ŠคํŠธ๋ฆญ ์ •๋ณด ์ƒ์„ฑ (ํ˜„์žฌ ์›” ๋ฐ์ดํ„ฐ ํ™œ์šฉ) + Map recordCountPerDay = getCurrentStreakData(user); + Integer maxStreak = getMaxStreak(user); + MainPageResponseDTO.StreakDTO streak = MainPageResponseDTO.StreakDTO.builder() + .maxStreak(maxStreak) + .recordCountPerDay(recordCountPerDay) + .build(); + + // 4. ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ธฐ๋ก ์ˆ˜ ์กฐํšŒ (์‹ค์ œ ๋ฐ์ดํ„ฐ) + List categories = getCategoryCountData(user); + + MainPageResponseDTO response = MainPageResponseDTO.builder() + .following(following) + .profile(profile) + .streak(streak) + .categories(categories) + .isFollowing(null) // ๋ณธ์ธ ํŽ˜์ด์ง€๋Š” null + .build(); + + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: ์นœ๊ตฌ์ˆ˜={}, ์นดํ…Œ๊ณ ๋ฆฌ์ˆ˜={}", + following.size(), categories.size()); + + return response; + } + + private Map getCurrentStreakData(User user) { + Map streakData = new HashMap<>(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // ํ˜„์žฌ ์›”์˜ ๋ชจ๋“  ๋‚ ์งœ์— ๋Œ€ํ•ด ๊ธฐ๋ก ์ˆ˜ ์กฐํšŒ + LocalDate today = LocalDate.now(); + YearMonth currentMonth = YearMonth.from(today); + int daysInMonth = currentMonth.lengthOfMonth(); + + for (int day = 1; day <= daysInMonth; day++) { + LocalDate date = LocalDate.of(currentMonth.getYear(), currentMonth.getMonth(), day); + Long recordCount = studyRecordRepository.countByUserAndCreateDateDate(user, date); + + if (recordCount > 0) { + streakData.put(date.format(formatter), recordCount.intValue()); + } + } + + return streakData; + } + + private Integer getMaxStreak(User user) { + return streakRepository.findByUser(user) + .map(Streak::getMaxStreak) + .orElse(0); + } + + private List getCategoryCountData(User user) { + // N+1 ์ฟผ๋ฆฌ ํ•ด๊ฒฐ: ํ•œ ๋ฒˆ์˜ ์ฟผ๋ฆฌ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ธฐ๋ก ์ˆ˜ ์กฐํšŒ + List categoryCountResults = studyRecordRepository.findCategoryCountsByUser(user); + + Map categoryCountMap = categoryCountResults.stream() + .collect(Collectors.toMap( + result -> (Long) result[0], // categoryId + result -> ((Long) result[1]).intValue() // count + )); + + // ์นดํ…Œ๊ณ ๋ฆฌ ์ •๋ณด ์กฐํšŒ + List categories = categoryRepository.findByUserOrderByNameAsc(user); + + return categories.stream() + .map(category -> MainPageResponseDTO.CategoryCountDTO.builder() + .name(category.getName()) + .count(categoryCountMap.getOrDefault(category.getId(), 0)) + .build()) + .sorted((a, b) -> b.getCount().compareTo(a.getCount())) // count ๊ธฐ์ค€ ๋‚ด๋ฆผ์ฐจ์ˆœ ์ •๋ ฌ + .toList(); + } + + @Transactional(readOnly = true) + public MainPageResponseDTO getMainPageDataWithFollowStatus(User targetUser, User currentUser) { + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ (ํŒ”๋กœ์šฐ ํ™•์ธ): ๋Œ€์ƒ={}, ํ˜„์žฌ ์‚ฌ์šฉ์ž={}", + targetUser.getOauthId(), currentUser != null ? currentUser.getOauthId() : "guest"); + + // ๊ธฐ์กด ๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ + List following = friendRepositoryImpl.findFriendListByUser(targetUser); + + MainPageResponseDTO.ProfileDTO profile = MainPageResponseDTO.ProfileDTO.builder() + .userId(targetUser.getId()) + .coverImage(targetUser.getBackImage()) // null์ด๋ฉด null ๋ฐ˜ํ™˜ + .profileImage(targetUser.getProfileImage()) + .name(targetUser.getNickname()) + .intro(targetUser.getIntro()) + .level(targetUser.getLevel()) + .code(targetUser.getCode()) + .build(); + + Map recordCountPerDay = getCurrentStreakData(targetUser); + Integer maxStreak = getMaxStreak(targetUser); + MainPageResponseDTO.StreakDTO streak = MainPageResponseDTO.StreakDTO.builder() + .maxStreak(maxStreak) + .recordCountPerDay(recordCountPerDay) + .build(); + + List categories = getCategoryCountData(targetUser); + + // ํŒ”๋กœ์šฐ ์—ฌ๋ถ€ ํ™•์ธ + Boolean isFollowing = null; + if (currentUser != null && !currentUser.getId().equals(targetUser.getId())) { + isFollowing = friendRepository.existsByUserAndFriend(currentUser, targetUser); + } + + MainPageResponseDTO response = MainPageResponseDTO.builder() + .following(following) + .profile(profile) + .streak(streak) + .categories(categories) + .isFollowing(isFollowing) + .build(); + + log.info("๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: ์นœ๊ตฌ์ˆ˜={}, ์นดํ…Œ๊ณ ๋ฆฌ์ˆ˜={}, ํŒ”๋กœ์šฐ์—ฌ๋ถ€={}", + following.size(), categories.size(), isFollowing); + + return response; + } + + @Transactional(readOnly = true) + public MainPageResponseDTO getMainPageDataByCode(String code) { + log.info("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹œ์ž‘: code={}", code); + + // code๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ + User user = userRepository.findByCode(code) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค.")); + + log.info("์ฝ”๋“œ๋กœ ์‚ฌ์šฉ์ž ์กฐํšŒ ์™„๋ฃŒ: code={}, ์‚ฌ์šฉ์ž={}", code, user.getOauthId()); + + // ๊ธฐ์กด getMainPageData ๋ฉ”์„œ๋“œ ๋กœ์ง ์žฌ์‚ฌ์šฉ + MainPageResponseDTO response = getMainPageData(user); + + log.info("์ฝ”๋“œ๋กœ ๋ฉ”์ธ ํŽ˜์ด์ง€ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: code={}, ์นœ๊ตฌ์ˆ˜={}, ์นดํ…Œ๊ณ ๋ฆฌ์ˆ˜={}", + code, response.getFollowing().size(), response.getCategories().size()); + + return response; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/service/NotificationService.java b/src/main/java/org/example/studylog/service/NotificationService.java new file mode 100644 index 0000000..63a4e8b --- /dev/null +++ b/src/main/java/org/example/studylog/service/NotificationService.java @@ -0,0 +1,106 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import org.example.studylog.dto.notification.NotificationDTO; +import org.example.studylog.dto.notification.NotificationListResponseDTO; +import org.example.studylog.dto.notification.NotificationResponseDTO; +import org.example.studylog.entity.notification.Notification; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.BusinessException; +import org.example.studylog.exception.ErrorCode; +import org.example.studylog.repository.EmitterRepository; +import org.example.studylog.repository.NotificationRepository; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.util.TimeUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final static Long DEFAULT_TIMEOUT = 60 * 60 * 1000L; + private final static String NOTIFICATION_NAME = "notification"; + + private final EmitterRepository emitterRepository; + private final UserRepository userRepository; + private final NotificationRepository notificationRepository; + + public SseEmitter createEmitter(String oauthId) { + // (๊ตฌ๋… ์š”์ฒญ์ด ๋“ค์–ด์˜ค๋ฉด) ์ƒˆ๋กœ์šด SseEmitter ๊ฐ์ฒด๋ฅผ ๋งŒ๋“ ๋‹ค. + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); + + // oauthId๋กœ SseEmitter๋ฅผ ์ €์žฅํ•œ๋‹ค. + emitterRepository.save(oauthId, emitter); + + emitter.onCompletion(() -> emitterRepository.delete(oauthId)); + emitter.onTimeout(() -> emitterRepository.delete(oauthId)); + emitter.onError((e) -> emitterRepository.delete(oauthId)); + + // ์ฒซ ์—ฐ๊ฒฐ ์‹œ ์‘๋‹ต ๋”๋ฏธ ๋ฐ์ดํ„ฐ (503 ์—๋Ÿฌ ๋ฐฉ์ง€) + try { + emitter.send(SseEmitter.event().id("").name(NOTIFICATION_NAME).data("Connection completed")); + } catch (IOException e) { + throw new BusinessException(ErrorCode.NOTIFICATION_CONNECTION_ERROR); + } + + return emitter; + } + + // ํด๋ผ์ด์–ธํŠธ๋กœ ์•Œ๋ฆผ ๋ณด๋‚ด๊ธฐ + @Transactional + public void sendToClient(String oauthId, NotificationDTO dto) { + // ์•Œ๋ฆผ์„ DB์— ์ €์žฅํ•˜๊ธฐ + User user = userRepository.findByOauthId(oauthId); + Notification notification = Notification.builder() + .user(user) + .type(dto.getType()) + .content(dto.getContent()) + .build(); + notificationRepository.save(notification); + + // ์•Œ๋ฆผ ๋ณด๋‚ผ ๋Œ€์ƒ์˜ SSE ๊ฐ์ฒด๊ฐ€ ์žˆ๋‹ค๋ฉด ์•Œ๋ฆผ ์ „์†ก + SseEmitter emitter = emitterRepository.get(oauthId); + if(emitter != null){ + try { + NotificationResponseDTO resDTO = NotificationResponseDTO.builder() + .type(dto.getType().getLabel()) + .content(dto.getContent()).build(); + emitter.send(SseEmitter.event() + .name(NOTIFICATION_NAME) + .data(resDTO)); + } catch (IOException e){ + // IOException ๋ฐœ์ƒํ•˜๋ฉด ์ €์žฅ๋œ emitter๋ฅผ ์‚ญ์ œ + emitter.completeWithError(e); + emitterRepository.delete(oauthId); + } + } + } + + @Transactional + public List getNotificationList(String oauthId, boolean isRead) { + User user = userRepository.findByOauthId(oauthId); + List notifications = notificationRepository.findTop30ByUserOrderByCreatedAtDesc(user); + + List list = notifications.stream() + .map(NotificationListResponseDTO::from) + .collect(Collectors.toList()); + + // isRead๊ฐ€ true์ด๋ฉด ์ฝ์Œ ์ฒ˜๋ฆฌ + if (isRead) { + notifications.stream() + .filter(n -> !n.isRead()) // ์•„์ง ์ฝ์ง€ ์•Š์€ ๊ฒƒ๋งŒ + .forEach(n -> n.setRead(true)); + } + + // DTO ๋ณ€ํ™˜ ํ›„ ๋ฐ˜ํ™˜ + return notifications.stream() + .map(NotificationListResponseDTO::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/example/studylog/service/QuizService.java b/src/main/java/org/example/studylog/service/QuizService.java new file mode 100644 index 0000000..b27f979 --- /dev/null +++ b/src/main/java/org/example/studylog/service/QuizService.java @@ -0,0 +1,211 @@ +package org.example.studylog.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.RequiredArgsConstructor; +import org.example.studylog.client.ChatGptClient; +import org.example.studylog.dto.CategoryDTO; +import org.example.studylog.dto.quiz.CreateQuizRequestDTO; +import org.example.studylog.dto.quiz.QuizListResponseDTO; +import org.example.studylog.dto.quiz.QuizResponseDTO; +import org.example.studylog.dto.quiz.QuizSummaryDTO; +import org.example.studylog.dto.quiz.chatGPT.ChatGptRequest; +import org.example.studylog.dto.quiz.chatGPT.ChatGptResponse; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.quiz.Quiz; +import org.example.studylog.entity.quiz.QuizLevel; +import org.example.studylog.entity.quiz.QuizType; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.BusinessException; +import org.example.studylog.exception.ErrorCode; +import org.example.studylog.repository.CategoryRepository; +import org.example.studylog.repository.QuizRepository; +import org.example.studylog.repository.StudyRecordRepository; +import org.example.studylog.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class QuizService { + + private final StudyRecordRepository studyRecordRepository; + private final UserRepository userRepository; + private final QuizRepository quizRepository; + private final CategoryRepository categoryRepository; + + private final ChatGptClient chatGptClient; + private final ObjectMapper objectMapper; + + @Transactional + public List createQuiz(String oauthId, Long recordId, CreateQuizRequestDTO requestDTO) { + // ์œ ์ € ์กฐํšŒ + User user = userRepository.findByOauthId(oauthId); + + // ํ€ด์ฆˆ๋ฅผ ๋งŒ๋“ค ๊ธฐ๋ก ์กฐํšŒ + StudyRecord studyRecord = studyRecordRepository.findById(recordId) + .orElseThrow(() -> new BusinessException(ErrorCode.STUDY_RECORD_NOT_FOUND)); + // ์ด๋ฏธ ํ€ด์ฆˆ๊ฐ€ ์žˆ๋Š” ๊ธฐ๋ก์ด๋ฉด ์—๋Ÿฌ ๋ฐ˜ํ™˜ +// if(studyRecord.isQuizCreated()) +// throw new BusinessException(ErrorCode.QUIZ_ALREADY_EXISTS); + if (Boolean.TRUE.equals(studyRecord.isQuizCreated())) { + throw new BusinessException(ErrorCode.QUIZ_ALREADY_EXISTS); + } + + // ๊ธฐ๋ก์— ๋Œ€ํ•œ ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ (์ถ”ํ›„ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜ ์‹œ ํ•„์š”ํ•จ) + Category category = studyRecord.getCategory(); + + // ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ + String prompt = buildPrompt(requestDTO, studyRecord.getContent()); + + // GPT์—๊ฒŒ ๋ณด๋‚ผ ์š”์ฒญ ์ƒ์„ฑ + ChatGptRequest request = new ChatGptRequest( + "gpt-3.5-turbo", + List.of(new ChatGptRequest.Message("user", prompt)), + 0.7 + ); + + // GPT์—๊ฒŒ ์š”์ฒญ ๋ณด๋‚ด๊ณ  ์‘๋‹ต ๋ฐ›๊ธฐ + ChatGptResponse response = chatGptClient.getChatCompletions(request); + String json = response.getChoices().get(0).getMessage().getContent(); + + // ์‘๋‹ต ๋ฐ›์€ ๋ฌธ์ž์—ด์„ ํ‚ค-๊ฐ’ ์Œ์œผ๋กœ ํŒŒ์‹ฑ + List> quizList; + try { + quizList = objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException("ํ€ด์ฆˆ ํŒŒ์‹ฑ ์‹คํŒจ: " + json, e); + } + + // ํ€ด์ฆˆ ๋ชฉ๋ก์„ DB์— ์ €์žฅ + List quizzes = quizList.stream().map(map -> { + Quiz q = new Quiz(); + q.setQuestion(map.get("question")); + q.setAnswer(map.get("answer")); + q.setLevel(QuizLevel.fromLabel(map.get("level"))); + q.setType(QuizType.valueOf(map.get("type"))); + q.setCreatedAt(studyRecord.getCreateDate()); + q.setRecord(studyRecord); + q.setUser(user); + q.setCategory(studyRecord.getCategory()); + return q; + }).toList(); + + quizRepository.saveAll(quizzes); + + // ํ€ด์ฆˆ ์ƒ์„ฑ๋˜์—ˆ์œผ๋‹ˆ '๊ธฐ๋ก'์˜ isQuizCreated๋ฅผ true๋กœ ์„ค์ • + studyRecord.setQuizCreated(true); + + // ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + List quizResponseList = quizzes.stream().map(map -> { + QuizResponseDTO dto = QuizResponseDTO.from(map, category); + return dto; + }).toList(); + + return quizResponseList; + } + + private String buildPrompt(CreateQuizRequestDTO dto, String content){ + return String.format(""" + ๋„ˆ๋Š” ํ€ด์ฆˆ ์ƒ์„ฑ ๋„์šฐ๋ฏธ์•ผ. ์•„๋ž˜ ๋‚ด์šฉ์„ ๋ฐ”ํƒ•์œผ๋กœ ํ€ด์ฆˆ๋ฅผ ๋งŒ๋“ค์–ด์ค˜. + + ์ƒ์„ฑ ์กฐ๊ฑด + - ์ฃผ์ œ: %s + - ๋‚œ์ด๋„: %s + - ๊ฐœ์ˆ˜: %d๊ฐœ + - ๋ฌธ์ œ ์œ ํ˜•: OX ๋˜๋Š” ๋‹จ๋‹ตํ˜• ์ค‘์—์„œ ๋ฌด์ž‘์œ„๋กœ ์„ž์–ด์„œ ์ƒ์„ฑํ•ด์ค˜. + %s + + ์ฐธ๊ณ ์‚ฌํ•ญ์€ ๋ฌธ์ œ ์œ ํ˜•๋ณด๋‹ค **์šฐ์„ ๋˜๋Š” ๊ฐ•๋ ฅํ•œ ์กฐ๊ฑด**์ด์•ผ. ์˜ˆ๋ฅผ ๋“ค์–ด "๋‹จ๋‹ตํ˜•์œผ๋กœ๋งŒ ์ƒ์„ฑ"์ด๋ผ๋Š” ์ฐธ๊ณ ์‚ฌํ•ญ์ด ์žˆ๋‹ค๋ฉด, ๋ฌธ์ œ ์œ ํ˜•์€ ๋ฌด์กฐ๊ฑด ๋‹จ๋‹ตํ˜•์ด์—ฌ์•ผ ํ•ด. + + ์ถœ๋ ฅ ํ˜•์‹์€ JSON ๋ฐฐ์—ด๋กœ ๋‹ค์Œ ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ผ์ค˜. `type`์€ ๋ฐ˜๋“œ์‹œ ๋Œ€๋ฌธ์ž๋กœ ์ž‘์„ฑํ•ด. + + ๐Ÿ“Œ ๋‚œ์ด๋„๋ณ„ ๋ฌธ์ œ ์ƒ์„ฑ ๊ธฐ์ค€ (๋งค์šฐ ์ค‘์š” โ€” ๋ฐ˜๋“œ์‹œ ๋”ฐ๋ฅผ ๊ฒƒ): + - ํ•˜: ๊ธฐ์ดˆ ์ •์˜, ์šฉ์–ด ์„ค๋ช…, ๊ฐœ๋… ์•”๊ธฐ ๋ฌธ์ œ (ex. "~์ด๋ž€?", "~์˜ ์ •์˜๋Š”?") + - ์ค‘: ๊ฐœ๋… ์‘์šฉ, ์‚ฌ๋ก€ ๋ถ„์„, ์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ๋ฅผ ๋ฌป๋Š” ๋ฌธ์ œ (ex. "~์„ ์–ธ์ œ ์‚ฌ์šฉํ•˜๋‚˜์š”?", "์˜ˆ์‹œ ์ค‘ ์˜ฌ๋ฐ”๋ฅธ ๊ฒƒ์„ ๊ณ ๋ฅด์„ธ์š”") + - ์ƒ: ๋น„๊ต, ํ•œ๊ณ„, ๋‚ด๋ถ€ ๋™์ž‘ ์›๋ฆฌ, ์˜ˆ์™ธ ์ƒํ™ฉ ๋“ฑ ์‹ฌํ™” ๊ฐœ๋…์„ ๋ฌป๋Š” ๋ฌธ์ œ (ex. "~์™€ ~์˜ ์ฐจ์ด๋Š”?", "์™œ ~ํ•œ๊ฐ€์š”?", "๋‹ค์Œ ์ค‘ ํ‹€๋ฆฐ ์„ค๋ช…์€?") + + โ†’ ๋‚œ์ด๋„์— ๋”ฐ๋ผ ๋ฌธ์ œ์˜ **๋‚ด์šฉ ์ž์ฒด๊ฐ€ ๋‹ฌ๋ผ์•ผ ํ•˜๋ฉฐ**, ๋‹จ์ˆœํžˆ ๋ฌธ์žฅ ํ‘œํ˜„๋งŒ ๋‹ค๋ฅด๊ฒŒ ๋งŒ๋“ค๋ฉด ์•ˆ ๋œ๋‹ค. + โ†’ ๋‚œ์ด๋„๊ฐ€ ๋†’์„์ˆ˜๋ก ๋” ๊นŠ์€ ์ดํ•ด๊ฐ€ ํ•„์š”ํ•œ ์งˆ๋ฌธ์ด ๋˜๋„๋ก ๊ตฌ์„ฑํ•˜๋ผ. + โ†’ ๋™์ผํ•œ ๋‚ด์šฉ์„ ํ‘œํ˜„๋งŒ ๋‹ค๋ฅด๊ฒŒ ๋ฐ˜๋ณตํ•˜์ง€ ๋งˆ. ๊ฐ ๋‚œ์ด๋„๋ณ„๋กœ ์ฃผ์ œ์˜ ๋‹ค๋ฅธ ์ธก๋ฉด์„ ๋‹ค๋ค„์•ผ ํ•ด. + [ + { + "question": "...", + "answer": "...", + "type": "OX" ๋˜๋Š” "SHORT_ANSWER", + "level": ๋‚ด๊ฐ€ ์ œ์‹œํ•œ ๋‚œ์ด๋„ ๊ทธ๋Œ€๋กœ + } + ] + + ๋ฐ˜๋“œ์‹œ ์ง€์ผœ์•ผ ํ•  ์ž‘์„ฑ ๊ทœ์น™ + - question์—๋Š” ์ ˆ๋Œ€ ๋ฌธ์ œ ์œ ํ˜•(O/X ๋“ฑ)์„ ํฌํ•จํ•˜์ง€ ๋งˆ. ๋‹จ์ˆœํ•œ ๋ฌธ์ œ ๋‚ด์šฉ๋งŒ ์ ์–ด. + โŒ ์˜ˆ: "๋‹ค์Œ ์„ค๋ช…์ด ๋งž์œผ๋ฉด O, ํ‹€๋ฆฌ๋ฉด X๋ฅผ ๊ณ ๋ฅด์‹œ์˜ค." + โŒ ์˜ˆ: "์Šคํ”„๋ง ๋นˆ์€ ~์ด๋‹ค. (O/X)" + โœ… ์˜ˆ: "์Šคํ”„๋ง ๋นˆ์ด๋ž€?" + - answer์—๋Š” ์ •ํ™•ํ•œ ์ •๋‹ต์„ ์ ์–ด. OX์˜ ๊ฒฝ์šฐ "O" ๋˜๋Š” "X"๋กœ, ๋‹จ๋‹ตํ˜•์€ ๋ฌธ์žฅ์ด๋‚˜ ๋‹จ์–ด๋กœ ๋‹ต๋ณ€ํ•ด. + - level์€ ๋‚ด๊ฐ€ ์ค€ ๋‚œ์ด๋„๋ฅผ ๊ทธ๋Œ€๋กœ ๋„ฃ์–ด (์˜ˆ: "ํ•˜", "์ค‘", "์ƒ"). + - SHORT_ANSWER ์œ ํ˜•์˜ answer๋Š” ๋ฐ˜๋“œ์‹œ 20์ž ์ด๋‚ด๋กœ ์ž‘์„ฑํ•ด์•ผ ํ•ด. (ํ•œ ์ค„ ์š”์•ฝ ํ˜•ํƒœ) + ์˜ˆ: "๋นˆ์€ ์Šคํ”„๋ง์ด ๊ด€๋ฆฌํ•˜๋Š” ๊ฐ์ฒด" + โŒ ์˜ˆ: "XML ์„ค์ • ํŒŒ์ผ, ์ž๋ฐ” ๊ธฐ๋ฐ˜ ์„ค์ • ํด๋ž˜์Šค..." + - ๋‹จ๋‹ตํ˜•์€ '๋ฌด์—‡์ธ๊ฐ€์š”?', '์ด์œ ๋Š”?', '์šฉ๋„๋Š”?' ๋“ฑ ๊ฐ„๊ฒฐํ•œ ์งˆ๋ฌธ์œผ๋กœ ๊ตฌ์„ฑํ•ด. + - "๋‚˜์—ดํ•ด๋ณด์„ธ์š”", "์„ค๋ช…ํ•˜์„ธ์š”", "์ž‘์„ฑํ•ด๋ณด์„ธ์š”" ๊ฐ™์€ ํ‘œํ˜„์€ ์‚ฌ์šฉํ•˜์ง€ ๋งˆ. (๊ธด ๋‹ต๋ณ€ ์œ ๋„ ๊ธˆ์ง€) + + ์ถœ๋ ฅ์€ JSON ๋ฐฐ์—ด๋กœ๋งŒ ์‘๋‹ตํ•ด. ์„ค๋ช…์ด๋‚˜ ์—ฌ๋Š” ๋ง ์—†์ด JSON๋งŒ ๋ฐ˜ํ™˜ํ•ด์ค˜. + """, + content, + dto.getLevel().getLabel(), + dto.getQuizCount(), + dto.getRequirement() != null ? "- ์ฐธ๊ณ ์‚ฌํ•ญ: " + dto.getRequirement() : "" + ); + } + + @Transactional(readOnly = true) + public QuizResponseDTO getQuiz(String oauthId, Long quizId) { + // ์œ ์ € ์กฐํšŒ + User user = userRepository.findByOauthId(oauthId); + + // ํ€ด์ฆˆ ์กฐํšŒ + Quiz quiz = quizRepository.findById(quizId) + .orElseThrow(() -> new IllegalArgumentException("ํ€ด์ฆˆ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")); + + // ํ•ด๋‹น ํ€ด์ฆˆ๊ฐ€ ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž์˜ ๊ฒƒ์ธ์ง€ ํ™•์ธ + if(user != quiz.getUser()) + throw new IllegalArgumentException("ํ˜„์žฌ ์œ ์ €์˜ ํ€ด์ฆˆ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."); + + // ํ€ด์ฆˆ์˜ ์นดํ…Œ๊ณ ๋ฆฌ ๊ฐ€์ ธ์˜ค๊ธฐ + Category category = quiz.getCategory(); + + // ์‘๋‹ต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + QuizResponseDTO dto = QuizResponseDTO.from(quiz, category); + + return dto; + } + + @Transactional(readOnly = true) + public QuizListResponseDTO getQuizList(String oauthId, String query, Long lastId, int size, LocalDate date, Long categoryId) { + // ๋กœ๊ทธ์ธํ•œ ์œ ์ € ์กฐํšŒ + User user = userRepository.findByOauthId(oauthId); + + // ์กฐ๊ฑด์— ๋งž๋Š” ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ + List quizzes = quizRepository.findQuizzes(user.getId(), lastId, size + 1, date, categoryId, query); + + // ์œ ์ €์˜ ์นดํ…Œ๊ณ ๋ฆฌ ์กฐํšŒ + List categories = categoryRepository.findByUserOrderByNameAsc(user); + + // ๋‹ค์Œ ๋ฐ์ดํ„ฐ ์กด์žฌ ์—ฌ๋ถ€ + boolean hasNext = quizzes.size() > size; + if (hasNext) quizzes.remove(size); // ์ปค์„œ ํ™•์ธ์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉํ•œ ๋งˆ์ง€๋ง‰ 1๊ฐœ ์ œ๊ฑฐ + + return QuizListResponseDTO.builder() + .categories(categories.stream().map(CategoryDTO::from).toList()) + .quizzes(quizzes.stream().map(QuizSummaryDTO::from).toList()) + .hasNext(hasNext) + .lastId(quizzes.isEmpty() ? null : quizzes.get(quizzes.size()-1).getId()) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/service/StreakService.java b/src/main/java/org/example/studylog/service/StreakService.java new file mode 100644 index 0000000..222d8db --- /dev/null +++ b/src/main/java/org/example/studylog/service/StreakService.java @@ -0,0 +1,55 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.StudyRecordRepository; +import org.example.studylog.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StreakService { + + private final StudyRecordRepository studyRecordRepository; + private final UserRepository userRepository; + + + + @Transactional(readOnly = true) + public Map getMonthlyStreakData(User user, String year, String month) { + log.info("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์‹œ์ž‘: ์‚ฌ์šฉ์ž={}, {}๋…„ {}์›”", user.getOauthId(), year, month); + + Map streakData = new LinkedHashMap<>(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + // YearMonth ๊ฐ์ฒด ์ƒ์„ฑ + int yearInt = Integer.parseInt(year); + int monthInt = Integer.parseInt(month); + YearMonth yearMonth = YearMonth.of(yearInt, monthInt); + + // ํ•ด๋‹น ์›”์˜ ๋ชจ๋“  ๋‚ ์งœ์— ๋Œ€ํ•ด ๊ธฐ๋ก ๊ฐœ์ˆ˜ ์กฐํšŒ + int daysInMonth = yearMonth.lengthOfMonth(); + + for (int day = 1; day <= daysInMonth; day++) { + LocalDate date = LocalDate.of(yearInt, monthInt, day); + Long recordCount = studyRecordRepository.countByUserAndCreateDateDate(user, date); + + // ๋ชจ๋“  ๋‚ ์งœ๋ฅผ ์ˆœ์„œ๋Œ€๋กœ LinkedHashMap์— ์ถ”๊ฐ€ + streakData.put(date.format(formatter), recordCount.intValue()); + } + + log.info("์›”๋ณ„ ์ŠคํŠธ๋ฆญ ๋ฐ์ดํ„ฐ ์กฐํšŒ ์™„๋ฃŒ: ์‚ฌ์šฉ์ž={}, {}๋…„ {}์›”, ์ด ์ผ์ˆ˜={}", + user.getOauthId(), year, month, streakData.size()); + + return streakData; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/service/StudyRecordService.java b/src/main/java/org/example/studylog/service/StudyRecordService.java new file mode 100644 index 0000000..a2900e9 --- /dev/null +++ b/src/main/java/org/example/studylog/service/StudyRecordService.java @@ -0,0 +1,392 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.CategoryDTO; +import org.example.studylog.dto.QuizDTO; +import org.example.studylog.dto.StreakDTO; +import org.example.studylog.dto.studyrecord.*; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.Streak; +import org.example.studylog.entity.user.User; +import org.example.studylog.event.RecordEvent; +import org.example.studylog.repository.CategoryRepository; +import org.example.studylog.repository.StudyRecordRepository; +import org.example.studylog.repository.StreakRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StudyRecordService { + + private final StudyRecordRepository studyRecordRepository; + private final CategoryRepository categoryRepository; + private final StreakRepository streakRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public StudyRecordDTO updateStudyRecord(User user, Long recordId, UpdateStudyRecordRequestDTO requestDTO) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ์ˆ˜์ • ์‹œ์ž‘: recordId={}", user.getOauthId(), recordId); + + // 1. ๊ธฐ๋ก ์กฐํšŒ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + StudyRecord studyRecord = studyRecordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ธฐ๋ก์ž…๋‹ˆ๋‹ค")); + + // ์ž‘์„ฑ์ž ํ™•์ธ + if (!studyRecord.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("ํ•ด๋‹น ๊ธฐ๋ก์„ ์ˆ˜์ •ํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + } + + // 2. ์นดํ…Œ๊ณ ๋ฆฌ ๊ฒ€์ฆ + Category category = categoryRepository.findByIdAndUser(requestDTO.getCategoryId(), user) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์นดํ…Œ๊ณ ๋ฆฌ์ž…๋‹ˆ๋‹ค")); + + // 3. ๊ธฐ๋ก ์ˆ˜์ • + studyRecord.setTitle(requestDTO.getTitle()); + studyRecord.setContent(requestDTO.getContent()); + studyRecord.setCategory(category); + + StudyRecord updatedStudyRecord = studyRecordRepository.save(studyRecord); + log.info("๊ธฐ๋ก ์ˆ˜์ • ์™„๋ฃŒ: ID={}", updatedStudyRecord.getId()); + + return convertToStudyRecordDTO(updatedStudyRecord); + } + + @Transactional + public void deleteStudyRecord(User user, Long recordId) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ์‚ญ์ œ ์‹œ์ž‘: recordId={}", user.getOauthId(), recordId); + + // 1. ๊ธฐ๋ก ์กฐํšŒ ๋ฐ ๊ถŒํ•œ ํ™•์ธ + StudyRecord studyRecord = studyRecordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ธฐ๋ก์ž…๋‹ˆ๋‹ค")); + + // ์ž‘์„ฑ์ž ํ™•์ธ + if (!studyRecord.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("ํ•ด๋‹น ๊ธฐ๋ก์„ ์‚ญ์ œํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + } + + // 2. ๊ธฐ๋ก ์‚ญ์ œ (์—ฐ๊ด€๋œ ํ€ด์ฆˆ๋“ค๋„ CASCADE๋กœ ํ•จ๊ป˜ ์‚ญ์ œ๋จ) + studyRecordRepository.delete(studyRecord); + + // recordCount ๊ฐ์†Œ + user.decrementRecordCount(); + log.info("๊ธฐ๋ก ์‚ญ์ œ ์ด๋ฒคํŠธ ๋ฐœํ–‰: USER={}, ID={}", user.getOauthId(), recordId); + eventPublisher.publishEvent(new RecordEvent(user)); + + log.info("๊ธฐ๋ก ์‚ญ์ œ ์™„๋ฃŒ: ID={}", recordId); + } + + @Transactional + public CreateStudyRecordResponseDTO createStudyRecord(User user, CreateStudyRecordRequestDTO requestDTO) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ์ƒ์„ฑ ์‹œ์ž‘", user.getOauthId()); + + // 1. ์นดํ…Œ๊ณ ๋ฆฌ ๊ฒ€์ฆ (ํ•„์ˆ˜) + Category category = categoryRepository.findByIdAndUser(requestDTO.getCategoryId(), user) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์นดํ…Œ๊ณ ๋ฆฌ์ž…๋‹ˆ๋‹ค")); + + // 2. ๊ธฐ๋ก ์ƒ์„ฑ + StudyRecord studyRecord = StudyRecord.builder() + .user(user) + .category(category) + .title(requestDTO.getTitle()) + .content(requestDTO.getContent()) + .isQuizCreated(false) // ํŒ€์› ์ฝ”๋“œ์— ๋งž์ถฐ isQuizCreated ์‚ฌ์šฉ + .build(); + + StudyRecord savedStudyRecord = studyRecordRepository.save(studyRecord); + log.info("๊ธฐ๋ก ์ƒ์„ฑ ์™„๋ฃŒ: ID={}", savedStudyRecord.getId()); + + // 3. ์ŠคํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ + StreakDTO streakDTO = updateUserStreak(user); + + // recordCount ์ฆ๊ฐ€ & ์ด๋ฒคํŠธ ๋ฐœํ–‰ + log.info("๊ธฐ๋ก ์ƒ์„ฑ ์ด๋ฒคํŠธ ๋ฐœํ–‰: USER={}, ID={}", user.getOauthId(), savedStudyRecord.getId()); + user.incrementRecordCount(); + eventPublisher.publishEvent(new RecordEvent(user)); + log.info("๊ธฐ๋ก ์ƒ์„ฑ ์ด๋ฒคํŠธ ์ข…๋ฃŒ: USER={}, ID={}", user.getOauthId(), savedStudyRecord.getId()); + + // 4. ์‘๋‹ต DTO ์ƒ์„ฑ + StudyRecordDTO recordDTO = convertToStudyRecordDTO(savedStudyRecord); + + return CreateStudyRecordResponseDTO.builder() + .record(recordDTO) + .streak(streakDTO) + .build(); + } + + @Transactional(readOnly = true) + public StudyRecordDetailResponseDTO getStudyRecordDetail(User user, Long recordId) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ์ƒ์„ธ ์กฐํšŒ: recordId={}", user.getOauthId(), recordId); + + // N+1 ๋ฐฉ์ง€: JOIN FETCH๋กœ ์นดํ…Œ๊ณ ๋ฆฌ์™€ ํ€ด์ฆˆ ์ •๋ณด๊นŒ์ง€ ํ•œ ๋ฒˆ์— ์กฐํšŒ + StudyRecord studyRecord = studyRecordRepository.findByIdWithCategoryAndQuizzes(recordId) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ธฐ๋ก์ž…๋‹ˆ๋‹ค")); + + // ์ž‘์„ฑ์ž ํ™•์ธ + if (!studyRecord.getUser().getId().equals(user.getId())) { + throw new IllegalArgumentException("ํ•ด๋‹น ๊ธฐ๋ก์— ์ ‘๊ทผํ•  ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค"); + } + + // ๊ธฐ๋ก DTO ๋ณ€ํ™˜ + StudyRecordDetailDTO recordDTO = convertToStudyRecordDetailDTO(studyRecord); + + // ํ€ด์ฆˆ ๋ชฉ๋ก ์กฐํšŒ (ํ˜„์žฌ๋Š” ๋นˆ ๋ฆฌ์ŠคํŠธ, ๋‚˜์ค‘์— ํ€ด์ฆˆ ๊ธฐ๋Šฅ ๊ตฌํ˜„ ์‹œ ์ˆ˜์ •) + List quizzes = getQuizzesForStudyRecord(studyRecord); + + return StudyRecordDetailResponseDTO.builder() + .record(recordDTO) + .quizzes(quizzes) + .build(); + } + + @Transactional(readOnly = true) + public StudyRecordFilterResponseDTO getStudyRecordsWithFilter(User user, StudyRecordFilterRequestDTO requestDTO) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ํ•„ํ„ฐ๋ง ์กฐํšŒ: categoryId={}, date={}, lastId={}", + user.getOauthId(), requestDTO.getCategoryId(), requestDTO.getDate(), requestDTO.getLastId()); + + // ํŽ˜์ด์ง€ ํฌ๊ธฐ ์„ค์ • (์ตœ๋Œ€ 20๊ฐœ๋กœ ์ œํ•œ) + int pageSize = Math.min(requestDTO.getSize(), 20); + Pageable pageable = PageRequest.of(0, pageSize + 1); // +1๋กœ hasMore ํŒ๋‹จ + + List studyRecords; + Category category = null; + LocalDate filterDate = null; + + // ์นดํ…Œ๊ณ ๋ฆฌ ๊ฒ€์ฆ (์ œ๊ณต๋œ ๊ฒฝ์šฐ) + if (requestDTO.getCategoryId() != null) { + category = categoryRepository.findByIdAndUser(requestDTO.getCategoryId(), user) + .orElseThrow(() -> new IllegalArgumentException("์กด์žฌํ•˜์ง€ ์•Š๋Š” ์นดํ…Œ๊ณ ๋ฆฌ์ž…๋‹ˆ๋‹ค")); + } + + // ๋‚ ์งœ ํŒŒ์‹ฑ (์ œ๊ณต๋œ ๊ฒฝ์šฐ) + if (requestDTO.getDate() != null && !requestDTO.getDate().trim().isEmpty()) { + try { + filterDate = LocalDate.parse(requestDTO.getDate()); + } catch (Exception e) { + throw new IllegalArgumentException("์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๋‚ ์งœ ํ˜•์‹์ž…๋‹ˆ๋‹ค. YYYY-MM-DD ํ˜•์‹์œผ๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”"); + } + } + + // ํ•„ํ„ฐ ์กฐ๊ฑด์— ๋”ฐ๋ผ ์ฟผ๋ฆฌ ์‹คํ–‰ + if (category != null && filterDate != null) { + // ์นดํ…Œ๊ณ ๋ฆฌ + ๋‚ ์งœ ํ•„ํ„ฐ + studyRecords = studyRecordRepository.findByUserAndCategoryAndDateWithPagination( + user, category, filterDate, requestDTO.getLastId(), pageable); + } else if (category != null) { + // ์นดํ…Œ๊ณ ๋ฆฌ๋งŒ ํ•„ํ„ฐ + studyRecords = studyRecordRepository.findByUserAndCategoryWithPagination( + user, category, requestDTO.getLastId(), pageable); + } else if (filterDate != null) { + // ๋‚ ์งœ๋งŒ ํ•„ํ„ฐ + studyRecords = studyRecordRepository.findByUserAndDateWithPagination( + user, filterDate, requestDTO.getLastId(), pageable); + } else { + // ํ•„ํ„ฐ ์—†์Œ (์ „์ฒด ์กฐํšŒ) + studyRecords = studyRecordRepository.findByUserWithPagination( + user, requestDTO.getLastId(), pageable); + } + + // hasMore ํŒ๋‹จ ๋ฐ ์‹ค์ œ ๋ฐ˜ํ™˜ํ•  ๋ฐ์ดํ„ฐ ์ถ”์ถœ + boolean hasMore = studyRecords.size() > pageSize; + List actualRecords = hasMore ? + studyRecords.subList(0, pageSize) : studyRecords; + + // DTO ๋ณ€ํ™˜ + List recordDTOs = actualRecords.stream() + .map(this::convertToStudyRecordDTO) + .collect(Collectors.toList()); + + // ๋‹ค์Œ lastId ๊ณ„์‚ฐ + Long nextLastId = null; + if (hasMore && !actualRecords.isEmpty()) { + nextLastId = actualRecords.get(actualRecords.size() - 1).getId(); + } + + log.info("ํ•„ํ„ฐ๋ง ์กฐํšŒ ๊ฒฐ๊ณผ: {}๊ฑด, hasMore={}", recordDTOs.size(), hasMore); + + return StudyRecordFilterResponseDTO.builder() + .records(recordDTOs) + .hasMore(hasMore) + .nextLastId(nextLastId) + .build(); + } + + @Transactional(readOnly = true) + public StudyRecordListResponseDTO searchStudyRecordsByTitle(User user, String query) { + log.info("์‚ฌ์šฉ์ž {}์˜ ๊ธฐ๋ก ์ œ๋ชฉ ๊ฒ€์ƒ‰: query={}", user.getOauthId(), query); + + // ์ฟผ๋ฆฌ๊ฐ€ ๋น„์–ด์žˆ๋Š”์ง€ ํ™•์ธ + if (query == null || query.trim().isEmpty()) { + throw new IllegalArgumentException("๊ฒ€์ƒ‰์–ด๋Š” ๊ณต๋ฐฑ์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + + // ์ œ๋ชฉ์œผ๋กœ ๊ธฐ๋ก ๊ฒ€์ƒ‰ + List studyRecords = studyRecordRepository + .findByUserAndTitleContainingIgnoreCaseOrderByCreateDateDesc(user, query.trim()); + + log.info("๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ: {}๊ฑด", studyRecords.size()); + + // DTO๋กœ ๋ณ€ํ™˜ + List recordDTOs = studyRecords.stream() + .map(this::convertToStudyRecordDTO) + .collect(Collectors.toList()); + + return StudyRecordListResponseDTO.builder() + .records(recordDTOs) + .build(); + } + + @Transactional + public StreakDTO updateUserStreak(User user) { + LocalDate today = LocalDate.now(); + + // ์‚ฌ์šฉ์ž์˜ ์ŠคํŠธ๋ฆญ ์ •๋ณด ์กฐํšŒ ๋˜๋Š” ์ƒ์„ฑ + Streak streak = streakRepository.findByUser(user) + .orElse(Streak.builder() + .user(user) + .currentStreak(0) + .maxStreak(0) + .build()); + + boolean isStreakUpdated = false; + + // ์˜ค๋Š˜ ์ด๋ฏธ ๊ธฐ๋ก์„ ์ž‘์„ฑํ–ˆ๋Š”์ง€ ํ™•์ธ + if (streak.getLastRecordDate() == null || !streak.getLastRecordDate().equals(today)) { + + if (streak.getLastRecordDate() == null) { + // ์ฒซ ๊ธฐ๋ก + streak.setCurrentStreak(1); + isStreakUpdated = true; + } else if (streak.getLastRecordDate().equals(today.minusDays(1))) { + // ์—ฐ์† ๊ธฐ๋ก + streak.setCurrentStreak(streak.getCurrentStreak() + 1); + isStreakUpdated = true; + } else if (streak.getLastRecordDate().isBefore(today.minusDays(1))) { + // ์ŠคํŠธ๋ฆญ ๋Š๊น€ - ์ƒˆ๋กœ ์‹œ์ž‘ + streak.setCurrentStreak(1); + isStreakUpdated = true; + } + + // ์ตœ๋Œ€ ์ŠคํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ + if (streak.getCurrentStreak() > streak.getMaxStreak()) { + streak.setMaxStreak(streak.getCurrentStreak()); + } + + streak.setLastRecordDate(today); + streakRepository.save(streak); + + log.info("์ŠคํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ: ์‚ฌ์šฉ์ž={}, ํ˜„์žฌ์ŠคํŠธ๋ฆญ={}, ์—…๋ฐ์ดํŠธ์—ฌ๋ถ€={}", + user.getOauthId(), streak.getCurrentStreak(), isStreakUpdated); + } + + return StreakDTO.builder() + .currentStreak(streak.getCurrentStreak()) + .isStreakUpdated(isStreakUpdated) + .build(); + } + + private StudyRecordDTO convertToStudyRecordDTO(StudyRecord studyRecord) { + CategoryDTO categoryDTO = null; + if (studyRecord.getCategory() != null) { + // Color enum์„ ๋ฌธ์ž์—ด๋กœ ๋ณ€ํ™˜ (์˜ˆ: BABY_BLUE -> "baby_blue") + String colorValue = studyRecord.getCategory().getColor().name().toLowerCase(); + + categoryDTO = CategoryDTO.builder() + .id(studyRecord.getCategory().getId()) + .name(studyRecord.getCategory().getName()) + .color(colorValue) + .build(); + } + + // BaseEntity์˜ createDate ์‚ฌ์šฉํ•˜์—ฌ ๋‚ ์งœ๋ฅผ "2025-06-24" ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ + String formattedDate = studyRecord.getCreateDate().toLocalDate().toString(); + + return StudyRecordDTO.builder() + .id(studyRecord.getId()) + .title(studyRecord.getTitle()) + .content(studyRecord.getContent()) + .category(categoryDTO) + .createdAt(formattedDate) + .hasQuiz(studyRecord.isQuizCreated()) // isQuizCreated -> hasQuiz๋กœ ๋ณ€ํ™˜ + .build(); + } + + private StudyRecordDetailDTO convertToStudyRecordDetailDTO(StudyRecord studyRecord) { + CategoryDTO categoryDTO = null; + if (studyRecord.getCategory() != null) { + String colorValue = studyRecord.getCategory().getColor().name().toLowerCase(); + + categoryDTO = CategoryDTO.builder() + .id(studyRecord.getCategory().getId()) + .name(studyRecord.getCategory().getName()) + .color(colorValue) + .build(); + } + + // BaseEntity์˜ createDate ์‚ฌ์šฉํ•˜์—ฌ ๋‚ ์งœ๋ฅผ "2025-06-24" ํ˜•์‹์œผ๋กœ ๋ณ€ํ™˜ + String formattedDate = studyRecord.getCreateDate().toLocalDate().toString(); + + // ํ€ด์ฆˆ ๊ฐœ์ˆ˜ ๊ณ„์‚ฐ (ํ˜„์žฌ๋Š” 0, ๋‚˜์ค‘์— ์‹ค์ œ ํ€ด์ฆˆ ๊ฐœ์ˆ˜๋กœ ๋ณ€๊ฒฝ) + int quizCount = studyRecord.getQuizzes() != null ? studyRecord.getQuizzes().size() : 0; + + return StudyRecordDetailDTO.builder() + .id(studyRecord.getId()) + .title(studyRecord.getTitle()) + .content(studyRecord.getContent()) + .category(categoryDTO) + .createdAt(formattedDate) + .quizCount(quizCount) + .build(); + } + + private List getQuizzesForStudyRecord(StudyRecord studyRecord) { + // ํ˜„์žฌ๋Š” ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ (ํ€ด์ฆˆ ๊ธฐ๋Šฅ ๊ตฌํ˜„ ์‹œ ์‹ค์ œ ํ€ด์ฆˆ ์กฐํšŒ๋กœ ๋ณ€๊ฒฝ) + // TODO: Quiz ์—”ํ‹ฐํ‹ฐ์™€ QuizRepository ๊ตฌํ˜„ ํ›„ ์‹ค์ œ ํ€ด์ฆˆ ์กฐํšŒ + if (studyRecord.getQuizzes() == null || studyRecord.getQuizzes().isEmpty()) { + return List.of(); // ๋นˆ ๋ฆฌ์ŠคํŠธ ๋ฐ˜ํ™˜ + } + + return studyRecord.getQuizzes().stream() + .map(this::convertToQuizDTO) + .toList(); + } + + private QuizDTO convertToQuizDTO(org.example.studylog.entity.quiz.Quiz quiz) { + // Quiz ์—”ํ‹ฐํ‹ฐ๊ฐ€ ์™„์ „ํžˆ ๊ตฌํ˜„๋˜๋ฉด ์‹ค์ œ ๋ณ€ํ™˜ ๋กœ์ง ์ถ”๊ฐ€ + // ํ˜„์žฌ๋Š” ๊ธฐ๋ณธ ๊ตฌ์กฐ๋งŒ ์ œ๊ณต + String levelStr = convertQuizLevelToString(quiz.getLevel()); + String typeStr = convertQuizTypeToString(quiz.getType()); + + return QuizDTO.builder() + .id(quiz.getId()) + .question(quiz.getQuestion()) + .type(typeStr) + .level(levelStr) + .build(); + } + + private String convertQuizLevelToString(org.example.studylog.entity.quiz.QuizLevel level) { + return switch (level) { + case EASY -> "ํ•˜"; + case MEDIUM -> "์ค‘"; + case HARD -> "์ƒ"; + }; + } + + private String convertQuizTypeToString(org.example.studylog.entity.quiz.QuizType type) { + return switch (type) { + case OX -> "OX"; + case SHORT_ANSWER -> "SHORT_ANSWER"; + }; + } +} \ No newline at end of file diff --git a/src/main/java/org/example/studylog/service/TokenService.java b/src/main/java/org/example/studylog/service/TokenService.java new file mode 100644 index 0000000..0fa5b55 --- /dev/null +++ b/src/main/java/org/example/studylog/service/TokenService.java @@ -0,0 +1,94 @@ +package org.example.studylog.service; + +import io.jsonwebtoken.ExpiredJwtException; +import org.example.studylog.dto.oauth.TokenDTO; +import org.example.studylog.entity.RefreshEntity; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.TokenValidationException; +import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.repository.RefreshRepository; +import org.example.studylog.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.util.Date; + +@Service +public class TokenService { + + private final UserRepository userRepository; + private final RefreshRepository refreshRepository; + private final JWTUtil jwtUtil; + + public TokenService(UserRepository userRepository, RefreshRepository refreshRepository, JWTUtil jwtUtil) { + this.userRepository = userRepository; + this.refreshRepository = refreshRepository; + this.jwtUtil = jwtUtil; + } + + public void addRefreshEntity(String oauthId, String refresh, Long expiredMs){ + Date date = new Date(System.currentTimeMillis() + expiredMs); + + RefreshEntity refreshEntity = RefreshEntity.builder() + .oauthId(oauthId) + .refresh(refresh) + .expiration(date.toString()) + .build(); + + refreshRepository.save(refreshEntity); + } + + public void validateRefreshToken(String refresh){ + // refresh๊ฐ€ ์—†์„ ๋•Œ + if(refresh == null){ + throw new TokenValidationException("refresh token null"); + } + + // refresh ํ† ํฐ์˜ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ + try{ + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e){ + throw new TokenValidationException("invalid refresh token"); + } + + // ํ† ํฐ์ด refresh ์ธ์ง€ ํ™•์ธ + String category = jwtUtil.getCategory(refresh); + if(!category.equals("refresh")){ + throw new TokenValidationException("invalid refresh token"); + } + + // refreshToken์ด ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ + Boolean isExist = refreshRepository.existsByRefresh(refresh); + if(!isExist){ + throw new TokenValidationException("invalid refresh token"); + } + } + + public void replaceRefreshToken(String oldRefresh, String newRefresh, String oauthId, long expiredMs){ + refreshRepository.deleteByRefresh(oldRefresh); + addRefreshEntity(oauthId, newRefresh, expiredMs); + } + + public TokenDTO reissueAccessToken(String refresh) { + + // ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ๊ฒ€์ฆ + validateRefreshToken(refresh); + + // ์ƒˆ๋กœ์šด access ํ† ํฐ ๋ฐœ๊ธ‰ + String oauthId = jwtUtil.getOauthId(refresh); + String role = jwtUtil.getRole(refresh); + + String newAccess = jwtUtil.createJwt("access", oauthId, role, 86400000L); + String newRefresh = jwtUtil.createJwt("refresh", oauthId, role, 86400000L); + + // Refresh DB์— ๊ธฐ์กด์˜ Refresh ํ† ํฐ ์‚ญ์ œ ํ›„ ์ƒˆ๋กœ์šด Refresh ํ† ํฐ ์ €์žฅ + replaceRefreshToken(refresh, newRefresh, oauthId, 86400000L); + + User user = userRepository.findByOauthId(oauthId); + return TokenDTO.builder() + .refreshToken(newRefresh) + .accessToken(newAccess) + .code(user.getCode()) + .isNewUser(user.isProfileCompleted()) + .build(); + } +} diff --git a/src/main/java/org/example/studylog/service/UserService.java b/src/main/java/org/example/studylog/service/UserService.java new file mode 100644 index 0000000..bdb3905 --- /dev/null +++ b/src/main/java/org/example/studylog/service/UserService.java @@ -0,0 +1,137 @@ +package org.example.studylog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.*; +import org.example.studylog.entity.user.User; +import org.example.studylog.exception.UserNotFoundException; +import org.example.studylog.repository.FriendRepository; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.util.ResponseUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final AwsS3Service awsS3Service; + private final FriendRepository friendRepository; + + @Transactional + public ProfileResponseDTO createUserProfile(ProfileCreateRequestDTO request, String oauthId){ + // ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + MultipartFile file = request.getProfileImage(); + // S3 ์—…๋กœ๋“œ + String imageUrl = awsS3Service.uploadProfileImage(file, user); + + // User ์—”ํ‹ฐํ‹ฐ์— ํ”„๋กœํ•„ ์ •๋ณด ์—…๋ฐ์ดํŠธ + user.setNickname(request.getNickname()); + user.setIntro(request.getIntro()); + user.setProfileImage(imageUrl); + + // ํ”„๋กœํ•„ ์ •๋ณด ์—…๋ฐ์ดํŠธ ์ƒํƒœ ๋ฐ”๊พธ๊ธฐ + if(!user.isProfileCompleted()) + user.setProfileCompleted(true); + + userRepository.save(user); + + // ์ƒ์„ฑ๋œ ๋ฐ์ดํ„ฐ๋กœ ์‘๋‹ต ๊ฐ์ฒด ๋ฐ˜ํ™˜ + return ProfileResponseDTO.builder() + .nickname(user.getNickname()) + .intro(user.getIntro()) + .profileImage(imageUrl) + .build(); + } + + @Transactional + public ProfileResponseDTO updateUserProfile(ProfileUpdateRequestDTO request, String oauthId) { + // ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + // ๋‹‰๋„ค์ž„ ์ˆ˜์ • + if(request.getNickname() != null){ + user.setNickname(request.getNickname()); + } + + // ํ•œ์ค„ ์†Œ๊ฐœ ์ˆ˜์ • + if(request.getIntro() != null){ + user.setIntro(request.getIntro()); + } + + // ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์ˆ˜์ • + if(request.getProfileImage() != null){ + MultipartFile newImage = request.getProfileImage(); + // S3 ์—…๋กœ๋“œ + String imageUrl = awsS3Service.uploadProfileImage(newImage, user); + user.setProfileImage(imageUrl); + } + + // ์ˆ˜์ •๋œ ๋ฐ์ดํ„ฐ๋กœ ์‘๋‹ต ๊ฐ์ฒด ๋ฐ˜ํ™˜ + return ProfileResponseDTO.builder() + .nickname(user.getNickname()) + .intro(user.getIntro()) + .profileImage(user.getProfileImage()) + .build(); + } + + @Transactional(readOnly = true) + public ProfileResponseDTO getUserProfile(String oauthId) { + // ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + ProfileResponseDTO dto = ProfileResponseDTO.builder() + .nickname(user.getNickname()) + .intro(user.getIntro()) + .profileImage(user.getProfileImage()) + .build(); + + return dto; + } + + @Transactional(readOnly = true) + public UserInfoResponseDTO getUserInfo(String oauthId) { + // ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + Long count = friendRepository.countByUser(user); + UserInfoResponseDTO dto = UserInfoResponseDTO.builder() + .profileImage(user.getProfileImage()) + .nickname(user.getNickname()) + .intro(user.getIntro()) + .friendCount(count) + .code(user.getCode()) + .build(); + + return dto; + } + + @Transactional + public BackgroundDTO.ResponseDTO updateBackground(String oauthId, BackgroundDTO.RequestDTO dto) { + // ์œ ์ € ์ฐพ๊ธฐ + User user = userRepository.findByOauthId(oauthId); + + log.info("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์‹œ์ž‘: ์‚ฌ์šฉ์ž={}", oauthId); + + MultipartFile file = dto.getCoverImage(); + if(file.isEmpty()){ + throw new IllegalStateException("๋นˆ ํŒŒ์ผ์€ ์—…๋กœ๋“œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + // ์ด๋ฏธ์ง€ URL์„ DB์— ์ €์žฅ + String backImageUrl = awsS3Service.uploadBackImage(file, user); + user.setBackImage(backImageUrl); + + // ์‘๋‹ต ์ƒ์„ฑ + BackgroundDTO.ResponseDTO responseDTO = new BackgroundDTO.ResponseDTO(backImageUrl); + + log.info("๋ฐฐ๊ฒฝํ™”๋ฉด ์ˆ˜์ • ์™„๋ฃŒ: ์‚ฌ์šฉ์ž={}, ๋ฐฐ๊ฒฝํ™”๋ฉด url = {}", oauthId, backImageUrl); + + return responseDTO; + } +} diff --git a/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..24f6aeb --- /dev/null +++ b/src/main/java/org/example/studylog/service/oauth/CustomOAuth2UserService.java @@ -0,0 +1,105 @@ +package org.example.studylog.service.oauth; + +import lombok.extern.slf4j.Slf4j; +import org.example.studylog.dto.oauth.*; +import org.example.studylog.entity.user.Role; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.UserRepository; +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.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Random; +import java.util.UUID; + +@Service +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + public CustomOAuth2UserService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = super.loadUser(userRequest); + System.out.println(oAuth2User); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + System.out.println(registrationId); + + OAuth2Response oAuth2Response = null; + if (registrationId.equals("kakao")){ + oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); + + } else if (registrationId.equals("google")) { + oAuth2Response = new GoogleResponse(oAuth2User.getAttributes()); + } else { + return null; + } + + // ๋กœ๊ทธ์ธ ์™„๋ฃŒ์‹œ ๋กœ์ง + String oauthId = oAuth2Response.getProvider()+"_"+oAuth2Response.getProviderId(); + System.out.println("์œ ์ €๋„ค์ž„: " + oauthId); + + // oauthId์œผ๋กœ ๋กœ๊ทธ์ธํ•œ ์œ ์ €๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ + User existData = userRepository.findByOauthId(oauthId); + System.out.println("ํ˜„์žฌ ๋ฐ์ดํ„ฐ: " + existData); + + if (existData == null) { + User user = User.builder() + .nickname(oAuth2Response.getName()) + .profileImage(oAuth2Response.getProfileImage()) + .level(0) + .recordCount(0L) + .role(Role.ROLE_USER) + .isProfileCompleted(false) + .uuid(UUID.randomUUID()) + .code(generateCode()) + .oauthId(oauthId) + .build(); + + userRepository.save(user); + + UserDTO userDTO = new UserDTO(); + userDTO.setOauthId(oauthId); + userDTO.setNickname(oAuth2Response.getName()); + userDTO.setRole(String.valueOf(user.getRole())); + userDTO.setProfileCompleted(user.isProfileCompleted()); + + return new CustomOAuth2User(userDTO); + } + else { + UserDTO userDTO = new UserDTO(); + userDTO.setOauthId(existData.getOauthId()); + userDTO.setNickname(existData.getNickname()); + userDTO.setRole(String.valueOf(existData.getRole())); + userDTO.setProfileCompleted(existData.isProfileCompleted()); + + return new CustomOAuth2User(userDTO); + } + } + + private String generateCode(){ + String code; + do{ + code = createCode(5); + } while(userRepository.existsByCode(code)); + return code; + } + + private String createCode(int length){ + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + StringBuilder sb = new StringBuilder(); + Random rand = new Random(); + for(int i=0; i ResponseEntity> buildResponse(int statusCode, String message, T data) { + return ResponseEntity.status(statusCode) + .body(new ResponseDTO<>(statusCode, message, data)); + } +} diff --git a/src/main/java/org/example/studylog/util/TimeUtil.java b/src/main/java/org/example/studylog/util/TimeUtil.java new file mode 100644 index 0000000..1cadce8 --- /dev/null +++ b/src/main/java/org/example/studylog/util/TimeUtil.java @@ -0,0 +1,20 @@ +package org.example.studylog.util; + +import java.time.Duration; +import java.time.LocalDateTime; + +public class TimeUtil { + public static String formatTimeAgo(LocalDateTime createdAt) { + LocalDateTime now = LocalDateTime.now(); + Duration duration = Duration.between(createdAt, now); + + long minutes = duration.toMinutes(); + long hours = duration.toHours(); + long days = duration.toDays(); + + if (minutes < 1) return "๋ฐฉ๊ธˆ ์ „"; + if (minutes < 60) return minutes + "๋ถ„ ์ „"; + if (hours < 24) return hours + "์‹œ๊ฐ„ ์ „"; + return days + "์ผ ์ „"; + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..da0d063 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,13 @@ + + + + + ์นด์นด์˜ค ๋กœ๊ทธ์ธ + + +

์นด์นด์˜ค ๋กœ๊ทธ์ธ ํ…Œ์ŠคํŠธ

+ + + + + \ No newline at end of file diff --git a/src/test/java/org/example/studylog/controller/UserControllerTest.java b/src/test/java/org/example/studylog/controller/UserControllerTest.java new file mode 100644 index 0000000..af65fe7 --- /dev/null +++ b/src/test/java/org/example/studylog/controller/UserControllerTest.java @@ -0,0 +1,121 @@ +package org.example.studylog.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import org.example.studylog.dto.ProfileCreateRequestDTO; +import org.example.studylog.dto.ProfileResponseDTO; +import org.example.studylog.entity.user.User; +import org.example.studylog.jwt.JWTUtil; +import org.example.studylog.repository.UserRepository; +import org.example.studylog.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private UserRepository userRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + private JWTUtil jwtUtil; + + @Test + void ํ”„๋กœํ•„_์—…๋ฐ์ดํŠธ_์„ฑ๊ณต() throws Exception { + // given + // ProfileCheckFilter์—์„œ ์ฐธ์กฐํ•  User mock ๊ฐ์ฒด ์„ค์ • + User user = new User(); + user.setNickname("์‚ฌ์šฉ์ž"); + user.setIntro("ํ•œ์ค„ ์†Œ๊ฐœ์ž…๋‹ˆ๋‹ค."); + user.setProfileImage("https://example.com/test.png"); + user.setProfileCompleted(true); + + // ํ•ด๋‹น user๊ฐ€ ๋ฐ˜ํ™˜๋˜๋„๋ก userRepository mocking + when(userRepository.findByOauthId("abc1234")).thenReturn(user); + + MockMultipartFile profileImage = new MockMultipartFile( + "profileImage", "test.png", "image/png", "fake-image".getBytes()); + String nickname = "์‚ฌ์šฉ์ž11"; + String intro = "ํ•œ์ค„ ์†Œ๊ฐœ์ž…๋‹ˆ๋‹ค.11"; + + // ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ์‹œ, ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ธฐ๋Œ€ํ•˜๋Š” ์‘๋‹ต ๊ฐ’ + ProfileResponseDTO dto = ProfileResponseDTO.builder() + .nickname(nickname) + .intro(intro) + .profileImage("https://example.com/test.png") + .build(); + + when(userService.createUserProfile(any(ProfileCreateRequestDTO.class), eq("abc1234"))) + .thenReturn(dto); + + // access ํ† ํฐ์„ ์ฟ ํ‚ค์— ๋‹ด์•„ ์š”์ฒญ + String token = jwtUtil.createJwt("access", "abc1234", "ROLE_USER", 60000L); + mockMvc.perform(multipart("/users/profile") + .file(profileImage) + .param("nickname", nickname) + .param("intro", intro) + .with(request -> {request.setMethod("PUT"); return request; }) + .cookie(new Cookie("access", token)) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ")) + .andExpect(jsonPath("$.data.nickname").value("์‚ฌ์šฉ์ž11")) + .andExpect(jsonPath("$.data.intro").value("ํ•œ์ค„ ์†Œ๊ฐœ์ž…๋‹ˆ๋‹ค.11")); + } + + @Test + @WithMockUser(username = "abc1234") + void ํ”„๋กœํ•„_์กฐํšŒ_์„ฑ๊ณต() throws Exception { + // given + // ProfileCheckFilter์—์„œ ์ฐธ์กฐํ•  User mock ๊ฐ์ฒด ์„ค์ • + User user = new User(); + user.setNickname("์‚ฌ์šฉ์ž"); + user.setIntro("ํ•œ์ค„ ์†Œ๊ฐœ์ž…๋‹ˆ๋‹ค."); + user.setProfileImage("https://example.com/test.png"); + user.setProfileCompleted(true); + + // ํ•ด๋‹น user๊ฐ€ ๋ฐ˜ํ™˜๋˜๋„๋ก userRepository mocking + when(userRepository.findByOauthId("abc1234")).thenReturn(user); + + ProfileResponseDTO dto = ProfileResponseDTO.builder() + .nickname("์‚ฌ์šฉ์ž") + .intro("์•ˆ๋…•ํ•˜์„ธ์š”") + .profileImage("https://example.com/image.png") + .build(); + + when(userService.getUserProfile("abc1234")).thenReturn(dto); + + String token = jwtUtil.createJwt("access", "abc1234", "ROLE_USER", 60000L); + mockMvc.perform(get("/users/profile") + .cookie(new Cookie("access", token))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCode").value(200)) + .andExpect(jsonPath("$.message").value("์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ ์„ฑ๊ณต")) + .andExpect(jsonPath("$.data.nickname").value("์‚ฌ์šฉ์ž")); + + } +} \ No newline at end of file diff --git a/src/test/java/org/example/studylog/service/StudyRecordServiceTest.java b/src/test/java/org/example/studylog/service/StudyRecordServiceTest.java new file mode 100644 index 0000000..7db4331 --- /dev/null +++ b/src/test/java/org/example/studylog/service/StudyRecordServiceTest.java @@ -0,0 +1,287 @@ +package org.example.studylog.service; + +import org.example.studylog.dto.studyrecord.*; +import org.example.studylog.entity.Streak; +import org.example.studylog.entity.StudyRecord; +import org.example.studylog.entity.category.Category; +import org.example.studylog.entity.category.Color; +import org.example.studylog.entity.user.Role; +import org.example.studylog.entity.user.User; +import org.example.studylog.repository.CategoryRepository; +import org.example.studylog.repository.StreakRepository; +import org.example.studylog.repository.StudyRecordRepository; +import org.example.studylog.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +@Transactional +class StudyRecordServiceTest { + + @Autowired + private StudyRecordService studyRecordService; + @Autowired + private UserRepository userRepository; + @Autowired + private CategoryRepository categoryRepository; + @Autowired + private StudyRecordRepository studyRecordRepository; + @Autowired + private StreakRepository streakRepository; + + // === 1. ๋ฐ์ดํ„ฐ ๋ฌด๊ฒฐ์„ฑ ํ…Œ์ŠคํŠธ === + + @Test + @DisplayName("๊ธฐ๋ก ์ƒ์„ฑ ์ค‘ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์‚ญ์ œ๋˜๋Š” ๊ฒฝ์šฐ") + void createRecord_CategoryDeletedDuringCreation_ShouldFail() { + // Given + User user = createTestUser(); + Category category = createTestCategory(user); + CreateStudyRecordRequestDTO requestDTO = createValidRequestDTO(category.getId()); + + // ๋‹ค๋ฅธ ์Šค๋ ˆ๋“œ์—์„œ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ญ์ œ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + CompletableFuture.runAsync(() -> { + categoryRepository.delete(category); + categoryRepository.flush(); + }); + + // When & Then + assertThrows(IllegalArgumentException.class, + () -> studyRecordService.createStudyRecord(user, requestDTO)); + } + + @Test + @DisplayName("๊ธฐ๋ก ์ƒ์„ฑ ์‹œ ์ œ๋ชฉ ๊ธธ์ด ๊ฒฝ๊ณ„๊ฐ’ ํ…Œ์ŠคํŠธ") + @ParameterizedTest + @ValueSource(ints = {0, 1, 19, 20, 21, 50}) + void createRecord_TitleLengthBoundary(int titleLength) { + // Given + User user = createTestUser(); + Category category = createTestCategory(user); + + CreateStudyRecordRequestDTO requestDTO = new CreateStudyRecordRequestDTO(); + requestDTO.setCategoryId(category.getId()); + requestDTO.setTitle("a".repeat(titleLength)); + requestDTO.setContent("์œ ํšจํ•œ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค. ์ตœ์†Œ 10์ž ์ด์ƒ ์ž‘์„ฑ"); + + // When & Then + if (titleLength == 0 || titleLength > 20) { + assertThrows(Exception.class, + () -> studyRecordService.createStudyRecord(user, requestDTO)); + } else { + assertDoesNotThrow( + () -> studyRecordService.createStudyRecord(user, requestDTO)); + } + } + + @Test + @DisplayName("๊ธฐ๋ก ๋‚ด์šฉ์— ํŠน์ˆ˜ ๋ฌธ์ž ๋ฐ ์ด๋ชจ์ง€ ํฌํ•จ") + void createRecord_SpecialCharactersAndEmojis() { + // Given + User user = createTestUser(); + Category category = createTestCategory(user); + + String specialContent = "ํŠน์ˆ˜๋ฌธ์ž ํ…Œ์ŠคํŠธ !@#$%^&*()_+ ์ด๋ชจ์ง€ ํ…Œ์ŠคํŠธ ๐Ÿ˜€๐ŸŽ‰๐Ÿ“š " + + "HTML ํƒœ๊ทธ " + + "SQL ๋ฌธ์ž '; DROP TABLE study_record; --"; + + CreateStudyRecordRequestDTO requestDTO = new CreateStudyRecordRequestDTO(); + requestDTO.setCategoryId(category.getId()); + requestDTO.setTitle("ํŠน์ˆ˜๋ฌธ์ž ํ…Œ์ŠคํŠธ"); + requestDTO.setContent(specialContent); + + // When + CreateStudyRecordResponseDTO result = studyRecordService.createStudyRecord(user, requestDTO); + + // Then + assertThat(result.getRecord().getContent()).contains("ํŠน์ˆ˜๋ฌธ์ž ํ…Œ์ŠคํŠธ"); + // XSS ๊ณต๊ฒฉ ๋ฌธ์ž์—ด์ด ๊ทธ๋Œ€๋กœ ์ €์žฅ๋˜์ง€ ์•Š์•˜๋Š”์ง€ ํ™•์ธ + assertThat(result.getRecord().getContent()).doesNotContain("