diff --git a/.gitignore b/.gitignore index caa32e6..55ccd32 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ .idea/ -*.iml \ No newline at end of file +*.iml +### yml ### +application.yml + + +.env + +.vscode \ No newline at end of file diff --git a/README.md b/README.md index b7a35ad..0e493d9 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,4 @@ | | | | | | | 윤민섭 | 김혜림 | 박채연 | 박세웅 | 김민주 | | [Minsub](https://github.com/minsubyun1) | [kimhyerim01](https://github.com/kimhyerim01) | [yeonchaepark](https://github.com/yeonchaepark) | [hardwoong](https://github.com/hardwoong) | [calla1102](https://github.com/calla1102) | + diff --git a/retrigger.txt b/retrigger.txt new file mode 100644 index 0000000..ab6a307 --- /dev/null +++ b/retrigger.txt @@ -0,0 +1,2 @@ +// retrigger CI after base branch change +//ABCD CI after base branch change diff --git a/server/.gitignore b/server/.gitignore index c2065bc..a063734 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -35,3 +35,7 @@ out/ ### VS Code ### .vscode/ + + +### yml +application.yml \ No newline at end of file diff --git a/server/build.gradle b/server/build.gradle index 5116a3f..30bb27d 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -27,12 +27,23 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation("io.jsonwebtoken:jjwt-api:0.12.4") + implementation 'org.springframework.boot:spring-boot-starter-security' + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.4") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.4") compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'io.github.cdimascio:dotenv-java:3.0.0' + implementation 'me.paulschwarz:spring-dotenv:4.0.0' } tasks.named('test') { diff --git a/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateRequest.java b/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateRequest.java new file mode 100644 index 0000000..ecdf725 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateRequest.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.common.dto; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class NicknameUpdateRequest { + @Size(min = 2, max = 12, message = "닉네임은 2자 이상 12자 이하로 입력해주세요.") + @Pattern(regexp = ".*[a-zA-Z가-힣]+.*", message = "닉네임에는 한글 또는 영문자가 최소 1자 이상 포함되어야 합니다.") + private String nickname; + +} diff --git a/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateResponse.java b/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateResponse.java new file mode 100644 index 0000000..3260ca0 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/common/dto/NicknameUpdateResponse.java @@ -0,0 +1,10 @@ +package com.soopgyeol.api.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class NicknameUpdateResponse { + private String nickname; +} diff --git a/server/src/main/java/com/soopgyeol/api/common/exception/InsufficientBalanceException.java b/server/src/main/java/com/soopgyeol/api/common/exception/InsufficientBalanceException.java new file mode 100644 index 0000000..c255b86 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/common/exception/InsufficientBalanceException.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.common.exception; + +public class InsufficientBalanceException extends RuntimeException { + public InsufficientBalanceException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/common/exception/ItemAlreadyOwnedException.java b/server/src/main/java/com/soopgyeol/api/common/exception/ItemAlreadyOwnedException.java new file mode 100644 index 0000000..b76ee63 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/common/exception/ItemAlreadyOwnedException.java @@ -0,0 +1,8 @@ +package com.soopgyeol.api.common.exception; + +public class ItemAlreadyOwnedException extends RuntimeException { + public ItemAlreadyOwnedException(String message) { + super(message); + } +} + diff --git a/server/src/main/java/com/soopgyeol/api/config/DevDataInitializer.java b/server/src/main/java/com/soopgyeol/api/config/DevDataInitializer.java new file mode 100644 index 0000000..71f0234 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/config/DevDataInitializer.java @@ -0,0 +1,33 @@ +package com.soopgyeol.api.config; + +import com.soopgyeol.api.domain.user.Role; +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.boot.CommandLineRunner; + +@Component +@RequiredArgsConstructor +public class DevDataInitializer implements CommandLineRunner{ + + private final UserRepository userRepository; + + @Override + public void run(String... args) { + if(userRepository.count() == 0) { + User testUser = User.builder() + .nickname("테스트 유저") + .email("test@example.com") + .password("1234") + .role(Role.USER) + .provider(SocialLoginType.GOOGLE) + .build(); + + userRepository.save(testUser); + + System.out.println("테스트 유저가 자동 등록되었습니다. ID: " + testUser.getId()); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/config/DotEnvConfig.java b/server/src/main/java/com/soopgyeol/api/config/DotEnvConfig.java new file mode 100644 index 0000000..72fcd69 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/config/DotEnvConfig.java @@ -0,0 +1,17 @@ +package com.soopgyeol.api.config; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DotEnvConfig { + + @Bean + public Dotenv dotenv() { + return Dotenv.configure() + .directory(System.getProperty("user.dir")) // 👈 명확하게 루트 경로 지정 + .ignoreIfMissing() + .load(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/config/PasswordEncoderConfig.java b/server/src/main/java/com/soopgyeol/api/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..a552b09 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/config/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/config/SecurityConfig.java b/server/src/main/java/com/soopgyeol/api/config/SecurityConfig.java new file mode 100644 index 0000000..d4aaee4 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/config/SecurityConfig.java @@ -0,0 +1,65 @@ +package com.soopgyeol.api.config; + +import com.soopgyeol.api.service.jwt.JwtAuthFilter; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationDecision; +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.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.IpAddressAuthorizationManager; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http.csrf(csrf -> csrf.disable()); + + + http.sessionManagement(sm -> sm + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/dev-login").permitAll() + .requestMatchers( + "/oauth2/**", + "/login/oauth2/**", + "/login-success", + "/auth/oauth2/**", + "/favicon.ico", + "/auth/login", + "/oauth2/google/code-log", + "/api/v1/auth/oauth/oauth2/**", + "/api/v1/auth/oauth/**" + ).permitAll() + .anyRequest().authenticated() + ); + + + + + + http.exceptionHandling(eh -> eh + .authenticationEntryPoint( + (req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED))); + + http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + +} diff --git a/server/src/main/java/com/soopgyeol/api/config/auth/CustomUserDetails.java b/server/src/main/java/com/soopgyeol/api/config/auth/CustomUserDetails.java new file mode 100644 index 0000000..b7d5f2e --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/config/auth/CustomUserDetails.java @@ -0,0 +1,62 @@ +package com.soopgyeol.api.config.auth; + + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + + +public class CustomUserDetails implements UserDetails { + private final Long userId; + private final String username; + private final String password; + private final Collection authorities; + + public CustomUserDetails(Long userId, String username, String password, Collection authorities) { + this.userId = userId; + this.username = username; + this.password = password; + this.authorities = authorities; + } + + // userId getter + public Long getUserId() { + return userId; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/AuthController.java b/server/src/main/java/com/soopgyeol/api/controller/AuthController.java new file mode 100644 index 0000000..f92ac6b --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/AuthController.java @@ -0,0 +1,103 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.dto.oauth.OAuthLoginRequest; +import com.soopgyeol.api.dto.oauth.OAuthLoginResponse; +import com.soopgyeol.api.service.auth.GoogleOauth; +import com.soopgyeol.api.service.auth.KakaoOauth; +import com.soopgyeol.api.service.auth.OAuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.Map; + +//임시 토큰 코드 사용 시 활성화 +//import com.soopgyeol.api.domain.user.User; +//import com.soopgyeol.api.repository.UserRepository; +//import com.soopgyeol.api.service.jwt.JwtProvider; +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth/oauth") +public class AuthController { + + private final OAuthService oAuthService; + private final GoogleOauth googleOauth; + private final KakaoOauth kakaoOauth; + + @PostMapping("/login") + public OAuthLoginResponse login(@RequestBody OAuthLoginRequest request) { + return oAuthService.login(request); + } + + @GetMapping("/oauth2/google/url") + public ResponseEntity> getGoogleLoginUrl() { + String url = googleOauth.getOauthRedirectURL(); + return ResponseEntity.ok(Map.of("url", url)); + } + + @GetMapping("/oauth2/kakao/url") + public ResponseEntity> getKakaoLoginUrl() { + String url = kakaoOauth.getOauthRedirectURL(); + return ResponseEntity.ok(Map.of("url", url)); + } + + + @GetMapping("/oauth2/google/code-log") + public void googleAutoLogin(@RequestParam String code, HttpServletResponse response) throws IOException { + // 기존 login() 재활용 + OAuthLoginRequest loginRequest = OAuthLoginRequest.builder() + .provider("GOOGLE") + .code(code) + .build(); + + OAuthLoginResponse loginResponse = oAuthService.login(loginRequest); + + // JWT 꺼내기 + String jwtToken = loginResponse.getJwtToken(); + + // 앱용 딥링크로 변경 + response.sendRedirect("soopgyeol://oauth-callback/google?token=" + jwtToken); + + } + + + + + @GetMapping("/oauth2/kakao/code-log") + public void kakaoAutoLogin(@RequestParam String code, HttpServletResponse response) throws IOException { + OAuthLoginRequest loginRequest = OAuthLoginRequest.builder() + .provider("KAKAO") + .code(code) + .build(); + + OAuthLoginResponse loginResponse = oAuthService.login(loginRequest); + + String jwtToken = loginResponse.getJwtToken(); + + // 앱 용 딥링크로 변경 + response.sendRedirect("soopgyeol://oauth-callback/kakao?token=" + jwtToken); + } + + + + + // 임시 토큰 생성시 활성화 +// private final UserRepository userRepository; +// private final JwtProvider jwtProvider; +// @PostMapping("/dev-login") +// public OAuthLoginResponse devLogin(@RequestParam Long userId) { +// User user = userRepository.findById(userId) +// .orElseThrow(() -> new IllegalArgumentException("유저 없음")); +// +// String jwt = jwtProvider.createToken(user.getId(), user.getRole()); +// +// return OAuthLoginResponse.builder() +// .jwtToken(jwt) +// .isNewUser(false) +// .build(); +// } + + +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/BuyController.java b/server/src/main/java/com/soopgyeol/api/controller/BuyController.java new file mode 100644 index 0000000..d9f940e --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/BuyController.java @@ -0,0 +1,38 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.domain.buy.dto.*; +import com.soopgyeol.api.service.jwt.JwtProvider; +import com.soopgyeol.api.service.buy.BuyService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/items/buy") +@RequiredArgsConstructor +public class BuyController { + + private final BuyService buyService; + private final JwtProvider jwtProvider; + + @PostMapping + public ResponseEntity buyItem(@RequestHeader("Authorization") String authorizationHeader, + @RequestBody BuyRequest request) { + String token = authorizationHeader.replace("Bearer ", ""); + Long userId = jwtProvider.getUserId(token); + + BuyResult result = buyService.buyItem(userId, request.getItemId()); + + BuyResponse response = BuyResponse.builder() + .itemId(result.getItemId()) + .itemName(result.getItemName()) + .itemPrice(result.getItemPrice()) + .userMoneyBalance(result.getUserMoneyBalance()) + .message("구매가 완료되었습니다.") + .build(); + + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/controller/CarbonItemController.java b/server/src/main/java/com/soopgyeol/api/controller/CarbonItemController.java new file mode 100644 index 0000000..df3883d --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/CarbonItemController.java @@ -0,0 +1,37 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisRequest; +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.challenge.dto.AIChallengeSendingRequest; +import com.soopgyeol.api.service.carbon.CarbonAnalysisService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +@RestController +@RequestMapping("/carbon") +@RequiredArgsConstructor +public class CarbonItemController { + + private final CarbonAnalysisService carbonAnalysisService; + + @PostMapping("/analyze") + public ResponseEntity> analyzeCarbon( + @RequestBody CarbonAnalysisRequest request) { + CarbonAnalysisResponse response = carbonAnalysisService.analyzeAndSave(request.getUserInput()); + return ResponseEntity.ok(new ApiResponse<>(true, "검색 성공", response)); + } + + + @PostMapping("/analyze/keyword") + public ResponseEntity> analyzeByKeyword(@RequestBody AIChallengeSendingRequest request) { + CarbonAnalysisResponse response = carbonAnalysisService.analyzeByKeyword(request.getKeyword(), request.getCategory(), request.getChallengeId()); // 저장 없이 반환 + return ResponseEntity.ok(new ApiResponse<>(true, "챌린지 기반 검색 성공", response)); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/DailyChallengeController.java b/server/src/main/java/com/soopgyeol/api/controller/DailyChallengeController.java new file mode 100644 index 0000000..4b566cb --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/DailyChallengeController.java @@ -0,0 +1,47 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import com.soopgyeol.api.domain.challenge.dto.ChallengeCompleteRequest; +import com.soopgyeol.api.domain.challenge.dto.ChallengeCompleteResponse; +import com.soopgyeol.api.domain.challenge.dto.ChallengeTodayResponse; +import com.soopgyeol.api.domain.challenge.dto.UserChallengeHistoryDto; +import com.soopgyeol.api.service.dailychallenge.UserChallengeServiceImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/challenges") +@RequiredArgsConstructor +public class DailyChallengeController { + private final UserChallengeServiceImpl userChallengeService; + + @GetMapping("/today") + public ResponseEntity> getOrCreateTodayChallenge ( + @AuthenticationPrincipal CustomUserDetails userDetails + ){ + ChallengeTodayResponse dto = userChallengeService.getTodayChallengeForUser(userDetails.getUserId()); + return ResponseEntity.ok(new ApiResponse<>(true, "오늘의 챌린지 조회 성공", dto)); + } + + @PostMapping("/complete") + public ResponseEntity> completeChallenge(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody ChallengeCompleteRequest request){ + ChallengeCompleteResponse response = userChallengeService.completeChallenge(userDetails.getUserId(), request.getDailyChallengeId()); + return ResponseEntity.ok(new ApiResponse<>(true, "챌린지 포인트 지급 완료", response)); + } + + @GetMapping("/history") + public ResponseEntity>> getChallengeHistory( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List history = userChallengeService.getUserChallengeHistory(userDetails.getUserId()); + return ResponseEntity.ok(new ApiResponse<>(true, "챌린지 수행 이력 조회 성공", history)); + } + + +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/HeroStageController.java b/server/src/main/java/com/soopgyeol/api/controller/HeroStageController.java new file mode 100644 index 0000000..a8b4b31 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/HeroStageController.java @@ -0,0 +1,29 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.domain.stage.dto.HeroStageResponse; +import com.soopgyeol.api.service.hero.HeroStageService; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequiredArgsConstructor +public class HeroStageController { + + private final HeroStageService heroStageService; + + @GetMapping("/hero-stage") + public ResponseEntity> getHeroStageMessage( + @AuthenticationPrincipal CustomUserDetails userDetails) { + try { + HeroStageResponse response = heroStageService.getHeroStageMessage(userDetails.getUserId()); + return ResponseEntity.ok(new ApiResponse<>(true, "영웅 단계 메시지 조회 성공", response)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ApiResponse<>(false, e.getMessage(), null)); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/ItemController.java b/server/src/main/java/com/soopgyeol/api/controller/ItemController.java new file mode 100644 index 0000000..596c9cb --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/ItemController.java @@ -0,0 +1,60 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.domain.enums.ItemCategory; +import com.soopgyeol.api.domain.item.dto.ItemResponse; +import com.soopgyeol.api.domain.item.dto.DisplayResponse; +import com.soopgyeol.api.service.item.ItemService; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +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.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class ItemController { + private final ItemService itemService; + + @GetMapping("/items/category/{category}") + public ResponseEntity>> getItemsByUserAndCategory( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable ItemCategory category) { + List items = itemService.getItemsByUserIdAndCategory(userDetails.getUserId(), category); + return ResponseEntity.ok(new ApiResponse<>(true, "유저별 카테고리별 아이템 조회 성공", items)); + } + + @GetMapping("/items/displayed") + public ResponseEntity>> getDisplayedItemsByUser( + @AuthenticationPrincipal CustomUserDetails userDetails) { + List items = itemService.getDisplayedItemsByUserId(userDetails.getUserId()); + return ResponseEntity.ok(new ApiResponse<>(true, "유저의 전시 아이템 조회 성공", items)); + } + + @GetMapping("/items/inventory/category/{category}") + public ResponseEntity>> getBuyedItemsByUserAndCategory( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable ItemCategory category) { + List items = itemService.getBuyedItemsByUserIdAndCategory(userDetails.getUserId(), category); + return ResponseEntity.ok(new ApiResponse<>(true, "유저의 카테고리별 인벤토리(보유 아이템) 조회 성공", items)); + } + + @PatchMapping("/items/item/{itemId}/display") + public ResponseEntity> toggleDisplay( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long itemId) { + try { + DisplayResponse response = itemService.toggleDisplay(userDetails.getUserId(), itemId); + return ResponseEntity.ok(new ApiResponse<>(true, "전시 상태 변경 성공", response)); + } catch (IllegalStateException e) { + return ResponseEntity.status(409).body(new ApiResponse<>(false, e.getMessage(), null)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ApiResponse<>(false, e.getMessage(), null)); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/LoginSuccessController.java b/server/src/main/java/com/soopgyeol/api/controller/LoginSuccessController.java new file mode 100644 index 0000000..19c30b8 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/LoginSuccessController.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class LoginSuccessController { + + @GetMapping("/login-success") + public String loginSuccess() { + return "구글 로그인 성공했습니다."; + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/TreeStageController.java b/server/src/main/java/com/soopgyeol/api/controller/TreeStageController.java new file mode 100644 index 0000000..99bef86 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/TreeStageController.java @@ -0,0 +1,29 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.domain.stage.dto.TreeStageResponse; +import com.soopgyeol.api.service.stage.TreeStageService; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import lombok.RequiredArgsConstructor; +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.RestController; + +@RestController +@RequiredArgsConstructor +public class TreeStageController { + + private final TreeStageService treeStageService; + + @GetMapping("/tree-stage") + public ResponseEntity> getTreeStageMessage( + @AuthenticationPrincipal CustomUserDetails userDetails) { + try { + TreeStageResponse response = treeStageService.getTreeStageMessage(userDetails.getUserId()); + return ResponseEntity.ok(new ApiResponse<>(true, "단계 메시지 조회 성공", response)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ApiResponse<>(false, e.getMessage(), null)); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/UserCarbonLogController.java b/server/src/main/java/com/soopgyeol/api/controller/UserCarbonLogController.java new file mode 100644 index 0000000..ed6a678 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/UserCarbonLogController.java @@ -0,0 +1,56 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.ApiResponse; +import com.soopgyeol.api.config.auth.CustomUserDetails; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogRequest; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogResponse; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogSummaryResponse; +import com.soopgyeol.api.service.carbonlog.UserCarbonLogService; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/carbon/log") +@RequiredArgsConstructor +public class UserCarbonLogController { + private final UserCarbonLogService userCarbonLogService; + + @PostMapping + public ResponseEntity> saveLog( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody UserCarbonLogRequest request) { + request.setUserId(userDetails.getUserId()); + userCarbonLogService.saveCarbonLog(request); + return ResponseEntity.ok(new ApiResponse<>(true, "탄소 소비 기록 저장 완료", null)); + } + + + @GetMapping("/daily") + public ResponseEntity> getLogsByDate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + + UserCarbonLogSummaryResponse logs = userCarbonLogService.getLogsByUserIdAndDate(userDetails.getUserId(), date); + return ResponseEntity.ok(new ApiResponse<>(true, "조회 성공", logs)); + } + + @GetMapping("/daily/challenge") + public ResponseEntity> getChallengeLogsByDate( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + + UserCarbonLogSummaryResponse summaryResponse = userCarbonLogService.getChallengeLogsByUserIdAndDate( + userDetails.getUserId(), date + ); + + return ResponseEntity.ok(new ApiResponse<>(true, "챌린지 탄소 활동 조회 성공", summaryResponse)); + } + + +} diff --git a/server/src/main/java/com/soopgyeol/api/controller/UserController.java b/server/src/main/java/com/soopgyeol/api/controller/UserController.java new file mode 100644 index 0000000..b8dec11 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/controller/UserController.java @@ -0,0 +1,72 @@ +package com.soopgyeol.api.controller; + +import com.soopgyeol.api.common.dto.NicknameUpdateRequest; +import com.soopgyeol.api.common.dto.NicknameUpdateResponse; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.domain.user.dto.UserInfoResponse; +import com.soopgyeol.api.service.UserService; +import com.soopgyeol.api.service.jwt.JwtProvider; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + private final JwtProvider jwtProvider; + + @PatchMapping("/me/nickname") + public ResponseEntity updateNickname( + @RequestHeader("Authorization") String authorizationHeader, + @Valid @RequestBody NicknameUpdateRequest request) { + + String token = authorizationHeader.replace("Bearer ", ""); + Long userId = jwtProvider.getUserId(token); + + String updatedNickname = userService.updateNickname(userId, request.getNickname()); + return ResponseEntity.ok(new NicknameUpdateResponse(updatedNickname)); + } + + @GetMapping("/me") + public ResponseEntity getMyInfo( + @RequestHeader("Authorization") String authorizationHeader) { + + String token = authorizationHeader.replace("Bearer ", ""); + Long userId = jwtProvider.getUserId(token); + + User user = userService.getUserById(userId); + + UserInfoResponse response = new UserInfoResponse( + user.getId(), + user.getEmail(), + user.getNickname()); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/me") + public ResponseEntity deleteMe(@RequestHeader("Authorization") String authorizationHeader) { + String token = authorizationHeader.replace("Bearer ", ""); + Long userId = jwtProvider.getUserId(token); + try { + userService.deleteUser(userId); + return ResponseEntity.ok().body( + java.util.Map.of( + "success", true, + "message", "회원 탈퇴 성공", + "data", userId)); + } catch (RuntimeException e) { + return ResponseEntity.status(404).body( + java.util.Map.of( + "success", false, + "message", e.getMessage(), + "data", null)); + } + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyRequest.java b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyRequest.java new file mode 100644 index 0000000..af8e5cd --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyRequest.java @@ -0,0 +1,8 @@ +package com.soopgyeol.api.domain.buy.dto; + +import lombok.Getter; + +@Getter +public class BuyRequest { + private Long itemId; // 구매하려는 아이템 ID +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResponse.java b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResponse.java new file mode 100644 index 0000000..1664fff --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResponse.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.domain.buy.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +@AllArgsConstructor +public class BuyResponse { + private Long itemId; + private String itemName; + private int itemPrice; + private int userMoneyBalance; + private String message; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResult.java b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResult.java new file mode 100644 index 0000000..56953ca --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/buy/dto/BuyResult.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.domain.buy.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class BuyResult { + private Long itemId; + private String itemName; + private int itemPrice; + private int userMoneyBalance; +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/buy/entity/Purchase.java b/server/src/main/java/com/soopgyeol/api/domain/buy/entity/Purchase.java new file mode 100644 index 0000000..4a30473 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/buy/entity/Purchase.java @@ -0,0 +1,36 @@ +package com.soopgyeol.api.domain.buy.entity; + +import com.soopgyeol.api.domain.user.User; +import jakarta.persistence.*; +import lombok.*; +import com.soopgyeol.api.domain.item.entity.Item; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Purchase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "buy_id") + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; // 구매자 (FK) + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "item_id") + private Item item; // 구매한 아이템 (FK) + + @Column(name = "item_money", nullable = false) + private int itemMoney; + + @CreationTimestamp + @Column(name = "purchased_at", nullable = false, updatable = false) + private LocalDateTime purchasedAt; // 구매 시각 +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisRequest.java b/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisRequest.java new file mode 100644 index 0000000..0d98e3b --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisRequest.java @@ -0,0 +1,8 @@ +package com.soopgyeol.api.domain.carbon.dto; + +import lombok.Data; + +@Data +public class CarbonAnalysisRequest { + private String userInput; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisResponse.java b/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisResponse.java new file mode 100644 index 0000000..4f33d9f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/carbon/dto/CarbonAnalysisResponse.java @@ -0,0 +1,23 @@ +package com.soopgyeol.api.domain.carbon.dto; + +import com.soopgyeol.api.domain.enums.Category; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CarbonAnalysisResponse { + private Long carbonItemId; + private String name; // 상품명 + private double carbonGrams; // 탄소량 (g) + private Category category; // 카테고리 + private String categoryKorean; // 카테고리(한글) + private int growthPoint; // 단위당 성장 점수 + private String explanation; // 왜 이 정도 탄소가 나왔는지 설명 + private String categoryImageUrl; + private Long challengeId; // 챌린지 기반 검색인 경우만 +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/carbon/entity/CarbonItem.java b/server/src/main/java/com/soopgyeol/api/domain/carbon/entity/CarbonItem.java new file mode 100644 index 0000000..0eb7565 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/carbon/entity/CarbonItem.java @@ -0,0 +1,42 @@ +package com.soopgyeol.api.domain.carbon.entity; + +import com.soopgyeol.api.domain.enums.Category; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CarbonItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "carbon_item_id") + private Long id; + + @Column(nullable = false) + private String name; // 제품명 + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Category category; // 카테고리 + + @Column(name = "carbon_value", nullable = false) + private float carbonValue; // 단위당 탄소량 (g 기준) + + @Column(name = "growth_point", nullable = false) + private int growthPoint; + + @Column(columnDefinition = "TEXT") + private String explanation; // GPT가 제공한 탄소량 설명 + + @Column(nullable = false) + private LocalDateTime createdAt; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengePromptResult.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengePromptResult.java new file mode 100644 index 0000000..7ba671f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengePromptResult.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import com.soopgyeol.api.domain.enums.Category; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AIChallengePromptResult { + private String title; + private int goalCount; + private int rewardMoney; + private String carbonKeyword; + private Category category; +} + diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengeSendingRequest.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengeSendingRequest.java new file mode 100644 index 0000000..9dacbb0 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/AIChallengeSendingRequest.java @@ -0,0 +1,11 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import com.soopgyeol.api.domain.enums.Category; +import lombok.Data; + +@Data +public class AIChallengeSendingRequest { + String keyword; + Category category; + private Long challengeId; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteRequest.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteRequest.java new file mode 100644 index 0000000..f6f7206 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteRequest.java @@ -0,0 +1,9 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import lombok.Data; + +@Data +public class ChallengeCompleteRequest { + private Long userId; + private Long dailyChallengeId; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteResponse.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteResponse.java new file mode 100644 index 0000000..1ac203c --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeCompleteResponse.java @@ -0,0 +1,11 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ChallengeCompleteResponse { + private int reward; + private int totalBalance; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeTodayResponse.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeTodayResponse.java new file mode 100644 index 0000000..f8d7ff6 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/ChallengeTodayResponse.java @@ -0,0 +1,19 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import com.soopgyeol.api.domain.enums.Category; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ChallengeTodayResponse { + private Long challengeId; + private String title; + private int goalCount; + private int rewardMoney; + private String carbonKeyword; + private Category category; + private int progressCount; + private boolean isCompleted; + private String categoryImageUrl; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/DailyChallengeResponse.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/DailyChallengeResponse.java new file mode 100644 index 0000000..9ad93ff --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/DailyChallengeResponse.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import com.soopgyeol.api.domain.enums.Category; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DailyChallengeResponse { + private Long id; + private String title; + private int goalCount; + private int rewardMoney; + private String carbonKeyword; + private Category category; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/UserChallengeHistoryDto.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/UserChallengeHistoryDto.java new file mode 100644 index 0000000..d7df902 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/dto/UserChallengeHistoryDto.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.domain.challenge.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@AllArgsConstructor +@Builder +public class UserChallengeHistoryDto { + private String title; + private LocalDate createdAt; + private boolean isCompleted; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/challenge/entity/DailyChallenge.java b/server/src/main/java/com/soopgyeol/api/domain/challenge/entity/DailyChallenge.java new file mode 100644 index 0000000..1f14503 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/challenge/entity/DailyChallenge.java @@ -0,0 +1,46 @@ +package com.soopgyeol.api.domain.challenge.entity; + +import com.soopgyeol.api.domain.enums.Category; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DailyChallenge { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "daily_challenge_id") + private Long id; + + private String title; + + @Column(name = "goal_count") + private int goalCount; + + @Column(name = "reward_money") + private int rewardMoney; + + @Column(name = "carbon_keyword") + private String carbonKeyword; + + @Column(name = "is_active") + private boolean isActive; + + @Column(name = "created_at", updatable = false) + @CreationTimestamp + private LocalDateTime createdAt; + + @Enumerated(EnumType.STRING) + private Category category; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/enums/Category.java b/server/src/main/java/com/soopgyeol/api/domain/enums/Category.java new file mode 100644 index 0000000..66ae6c7 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/enums/Category.java @@ -0,0 +1,37 @@ +package com.soopgyeol.api.domain.enums; + +public enum Category { + FOOD("음식", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/FOOD.jpg"), + TRANSPORT("교통", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/TRANSPORTATION.jpg"), + CLOTHING("의류", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/CLOTHING.jpg"), + HOUSING_ENERGY("주거 및 에너지", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/HOUSING_ENERGY.jpg"), + RECYCLE_WASTE("리사이클 & 폐기물", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/RECYCLE.jpg"), + LIFESTYLE_CONSUMPTION("라이프스타일 & 소비", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/LIFESTYLE.jpg"), + ETC("기타", "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/ETC.JPG"); + + private final String description; + private final String imageUrl; + + Category(String description, String imageUrl) { + this.description = description; + this.imageUrl = imageUrl; + } + + public String getDescription() { + return description; + } + + public String getImageUrl() { + return imageUrl; + } + + public static Category fromString(String value) { + for (Category category : Category.values()) { + if (category.name().equalsIgnoreCase(value.trim())) { + return category; + } + } + return ETC; // fallback + } + +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/enums/ItemCategory.java b/server/src/main/java/com/soopgyeol/api/domain/enums/ItemCategory.java new file mode 100644 index 0000000..11543d5 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/enums/ItemCategory.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.domain.enums; + +public enum ItemCategory { + SKY, + LEFT_GROUND, + RIGHT_GROUND, +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/item/dto/DisplayResponse.java b/server/src/main/java/com/soopgyeol/api/domain/item/dto/DisplayResponse.java new file mode 100644 index 0000000..0c91ae8 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/item/dto/DisplayResponse.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.domain.item.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DisplayResponse { + private Long itemId; + private boolean display; + private String message; +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/item/dto/ItemResponse.java b/server/src/main/java/com/soopgyeol/api/domain/item/dto/ItemResponse.java new file mode 100644 index 0000000..ee171ac --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/item/dto/ItemResponse.java @@ -0,0 +1,22 @@ +package com.soopgyeol.api.domain.item.dto; + +import com.soopgyeol.api.domain.enums.ItemCategory; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ItemResponse { + private Long id; + private String name; + private int price; + private String url; + private ItemCategory category; + private boolean display; + private boolean available; + private boolean isBuyed; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/item/entity/Inventory.java b/server/src/main/java/com/soopgyeol/api/domain/item/entity/Inventory.java new file mode 100644 index 0000000..025694f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/item/entity/Inventory.java @@ -0,0 +1,42 @@ +package com.soopgyeol.api.domain.item.entity; + + +import com.soopgyeol.api.domain.user.User; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "inventory") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Inventory { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_item_id") + private Long userItemId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id") + private Item item; + + // 전시 여부 + @Column(name = "is_displayed") + private boolean isDisplayed; + + // 보유(구매) 여부 + @Column(name = "is_buyed") + private boolean isBuyed; + + // 구매 시간 + @Column(name = "buy_at") + private LocalDateTime buyAt; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/item/entity/Item.java b/server/src/main/java/com/soopgyeol/api/domain/item/entity/Item.java new file mode 100644 index 0000000..c70289f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/item/entity/Item.java @@ -0,0 +1,34 @@ +package com.soopgyeol.api.domain.item.entity; + + +import com.soopgyeol.api.domain.enums.ItemCategory; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "item") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Item { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private int price; + + private String url; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ItemCategory category; + + public void setPrice(int price) { + this.price = price; + } +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageRequest.java b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageRequest.java new file mode 100644 index 0000000..3adbc51 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageRequest.java @@ -0,0 +1,12 @@ +package com.soopgyeol.api.domain.stage.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HeroStageRequest { + private Long userId; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageResponse.java b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageResponse.java new file mode 100644 index 0000000..f98748a --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/HeroStageResponse.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.domain.stage.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HeroStageResponse { + private String heroName; + private String heroUrl; +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageRequest.java b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageRequest.java new file mode 100644 index 0000000..f4b787a --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageRequest.java @@ -0,0 +1,12 @@ +package com.soopgyeol.api.domain.stage.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TreeStageRequest { + private Long userId; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageResponse.java b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageResponse.java new file mode 100644 index 0000000..da27b9d --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/stage/dto/TreeStageResponse.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.domain.stage.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TreeStageResponse { + private String treeName; + private String treeUrl; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/stage/entity/Stage.java b/server/src/main/java/com/soopgyeol/api/domain/stage/entity/Stage.java new file mode 100644 index 0000000..14b46ef --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/stage/entity/Stage.java @@ -0,0 +1,56 @@ +package com.soopgyeol.api.domain.stage.entity; + + +import com.soopgyeol.api.domain.user.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "stage") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Stage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // User와 1:1 관계 + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", unique = true) + private User user; + + // 트리 정보 + @Column(nullable = false) + private String treeName; + + private String treeUrl; + + // 히어로 정보 + @Column(nullable = false) + private String heroName; + + private String heroUrl; + + public void setUser(User user) { + this.user = user; + } + + public void setTreeName(String treeName) { + this.treeName = treeName; + } + + public void setTreeUrl(String treeUrl) { + this.treeUrl = treeUrl; + } + + public void setHeroName(String heroName) { + this.heroName = heroName; + } + + public void setHeroUrl(String heroUrl) { + this.heroUrl = heroUrl; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/user/Role.java b/server/src/main/java/com/soopgyeol/api/domain/user/Role.java new file mode 100644 index 0000000..0149ab9 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/user/Role.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.domain.user; + +import lombok.Getter; + +@Getter +public enum Role { + + USER("ROLE_USER"), + ADMIN("ROLE_ADMIN"); + private final String key; + + Role(String key) { + this.key = key; + } +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/user/SocialLoginType.java b/server/src/main/java/com/soopgyeol/api/domain/user/SocialLoginType.java new file mode 100644 index 0000000..b1883d3 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/user/SocialLoginType.java @@ -0,0 +1,5 @@ +package com.soopgyeol.api.domain.user; + +public enum SocialLoginType { + GOOGLE, KAKAO +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/user/User.java b/server/src/main/java/com/soopgyeol/api/domain/user/User.java new file mode 100644 index 0000000..2b74905 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/user/User.java @@ -0,0 +1,59 @@ +package com.soopgyeol.api.domain.user; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + private String password; + + private String nickname; + + @Enumerated(EnumType.STRING) + private Role role; // USER, ADMIN + + @Enumerated(EnumType.STRING) + private SocialLoginType provider; // GOOGLE, KAKAO + + private String socialId; + + @Column(name = "money_balance", nullable = false) + private int moneyBalance; + + @Column(name = "growth_point", nullable = false) + private int growthPoint; + + @CreationTimestamp + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + public void increaseGrowthPoint(int point) { + this.growthPoint += point; + } + + public void addMoney(int amount) { + this.moneyBalance += amount; + } + + public void subMoney(int money) {this.moneyBalance -= money; } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/user/dto/UserInfoResponse.java b/server/src/main/java/com/soopgyeol/api/domain/user/dto/UserInfoResponse.java new file mode 100644 index 0000000..ad612a0 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/user/dto/UserInfoResponse.java @@ -0,0 +1,12 @@ +package com.soopgyeol.api.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserInfoResponse { + private Long userId; + private String email; + private String nickname; +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/domain/userChallenge/entity/UserChallenge.java b/server/src/main/java/com/soopgyeol/api/domain/userChallenge/entity/UserChallenge.java new file mode 100644 index 0000000..705a6d5 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/userChallenge/entity/UserChallenge.java @@ -0,0 +1,59 @@ +package com.soopgyeol.api.domain.userChallenge.entity; + +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import com.soopgyeol.api.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserChallenge { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_challenge_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "daily_challenge_id") + private DailyChallenge dailyChallenge; + + @Column(name = "is_completed") + private boolean isCompleted; + + @Column(name = "progress_count") + private int progressCount; + + @Column(name = "reward_money") + private int rewardMoney; + + @Column(name = "completed_at") + private LocalDate completedAt; + + @Builder.Default + @Column(name = "reward_received", nullable = false) + private boolean rewardReceived = false; + + public void setRewardReceived(boolean rewardReceived) { + this.rewardReceived = rewardReceived; + } + + public void increaseProgress(int quantity) { + this.progressCount += quantity; + if (this.progressCount >= this.dailyChallenge.getGoalCount()) { + this.isCompleted = true; + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogRequest.java b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogRequest.java new file mode 100644 index 0000000..2ce1251 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogRequest.java @@ -0,0 +1,11 @@ +package com.soopgyeol.api.domain.usercarbonlog.dto; + +import lombok.Data; + +@Data +public class UserCarbonLogRequest { + private Long userId; + private Long carbonItemId; + private int quantity; + private Long dailyChallengeId; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogResponse.java b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogResponse.java new file mode 100644 index 0000000..f637c0a --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogResponse.java @@ -0,0 +1,11 @@ +package com.soopgyeol.api.domain.usercarbonlog.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserCarbonLogResponse { + private String product; + private int growthPoint; +} diff --git a/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogSummaryResponse.java b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogSummaryResponse.java new file mode 100644 index 0000000..75d6f06 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/dto/UserCarbonLogSummaryResponse.java @@ -0,0 +1,14 @@ +package com.soopgyeol.api.domain.usercarbonlog.dto; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class UserCarbonLogSummaryResponse { + private List logs; + private int totalGrowthPoint; +} + diff --git a/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/entity/UserCarbonLog.java b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/entity/UserCarbonLog.java new file mode 100644 index 0000000..3d77fdb --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/domain/usercarbonlog/entity/UserCarbonLog.java @@ -0,0 +1,53 @@ +package com.soopgyeol.api.domain.usercarbonlog.entity; + +import com.soopgyeol.api.domain.carbon.entity.CarbonItem; +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import com.soopgyeol.api.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserCarbonLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "log_id") + private Long id; + + // 소비한 사용자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + // 소비한 품목 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "carbon_item_id") + private CarbonItem carbonItem; + + // 소비 수량 + private int quantity; + + // 계산된 총 탄소량 + @Column(name = "calculated_carbon", nullable = false) + private float calculatedCarbon; + + @Column(name = "calculated_point", nullable = false) + private int growthPoint; + + // 저장 시각 + @Column(nullable = false) + private LocalDateTime recordedAt; + + @Column(nullable = false) + private boolean isFromChallenge; +} diff --git a/server/src/main/java/com/soopgyeol/api/dto/oauth/GoogleTokenResponse.java b/server/src/main/java/com/soopgyeol/api/dto/oauth/GoogleTokenResponse.java new file mode 100644 index 0000000..de3b208 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/dto/oauth/GoogleTokenResponse.java @@ -0,0 +1,19 @@ +package com.soopgyeol.api.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +@NoArgsConstructor +public class GoogleTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("id_token") + private String idToken; +} + diff --git a/server/src/main/java/com/soopgyeol/api/dto/oauth/KakaoTokenResponse.java b/server/src/main/java/com/soopgyeol/api/dto/oauth/KakaoTokenResponse.java new file mode 100644 index 0000000..7972034 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/dto/oauth/KakaoTokenResponse.java @@ -0,0 +1,28 @@ +package com.soopgyeol.api.dto.oauth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("expires_in") + private Long expiresIn; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("refresh_token_expires_in") + private Long refreshTokenExpiresIn; +} diff --git a/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginRequest.java b/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginRequest.java new file mode 100644 index 0000000..55a3489 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginRequest.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.dto.oauth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OAuthLoginRequest { + private String provider; + private String code; +} diff --git a/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginResponse.java b/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginResponse.java new file mode 100644 index 0000000..6be8775 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/dto/oauth/OAuthLoginResponse.java @@ -0,0 +1,15 @@ +package com.soopgyeol.api.dto.oauth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OAuthLoginResponse { + private String jwtToken; + private boolean isNewUser; +} diff --git a/server/src/main/java/com/soopgyeol/api/dto/oauth/SocialUserInfo.java b/server/src/main/java/com/soopgyeol/api/dto/oauth/SocialUserInfo.java new file mode 100644 index 0000000..af2d786 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/dto/oauth/SocialUserInfo.java @@ -0,0 +1,17 @@ +package com.soopgyeol.api.dto.oauth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class SocialUserInfo { + private String socialId; + private String email; + private String nickname; + +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/CarbonItemRepository.java b/server/src/main/java/com/soopgyeol/api/repository/CarbonItemRepository.java new file mode 100644 index 0000000..8462710 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/CarbonItemRepository.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.carbon.entity.CarbonItem; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CarbonItemRepository extends JpaRepository { +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/DailyChallengeRepository.java b/server/src/main/java/com/soopgyeol/api/repository/DailyChallengeRepository.java new file mode 100644 index 0000000..cbf78b9 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/DailyChallengeRepository.java @@ -0,0 +1,16 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; + +public interface DailyChallengeRepository extends JpaRepository { + Optional findByIsActiveTrue(); + + @Modifying + @Query("UPDATE DailyChallenge dc SET dc.isActive = false WHERE dc.isActive = true") + void deactivateAll(); +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/InventoryRepository.java b/server/src/main/java/com/soopgyeol/api/repository/InventoryRepository.java new file mode 100644 index 0000000..8064dd0 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/InventoryRepository.java @@ -0,0 +1,20 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.item.entity.Inventory; +import com.soopgyeol.api.domain.item.entity.Item; +import com.soopgyeol.api.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface InventoryRepository extends JpaRepository { + List findByUser(User user); + + List findByUserAndIsDisplayedTrue(User user); + + List findByUserAndIsBuyedTrue(User user); + + List findByUserAndItem(User user, Item item); + + void deleteByUser(User user); +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/repository/ItemRepository.java b/server/src/main/java/com/soopgyeol/api/repository/ItemRepository.java new file mode 100644 index 0000000..93966a3 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/ItemRepository.java @@ -0,0 +1,10 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.item.entity.Item; +import com.soopgyeol.api.domain.enums.ItemCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface ItemRepository extends JpaRepository { + List findByCategory(ItemCategory category); +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/PurchaseRepository.java b/server/src/main/java/com/soopgyeol/api/repository/PurchaseRepository.java new file mode 100644 index 0000000..2a85f51 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/PurchaseRepository.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.item.entity.Item; +import com.soopgyeol.api.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import com.soopgyeol.api.domain.buy.entity.Purchase; + +@Repository +public interface PurchaseRepository extends JpaRepository { + + boolean existsByUserAndItem(User user, Item item); +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/StageRepository.java b/server/src/main/java/com/soopgyeol/api/repository/StageRepository.java new file mode 100644 index 0000000..283a96b --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/StageRepository.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.stage.entity.Stage; +import com.soopgyeol.api.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface StageRepository extends JpaRepository { + Optional findByUser(User user); + + void deleteByUser(User user); +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/UserCarbonLogRepository.java b/server/src/main/java/com/soopgyeol/api/repository/UserCarbonLogRepository.java new file mode 100644 index 0000000..c5dc5f1 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/UserCarbonLogRepository.java @@ -0,0 +1,21 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.usercarbonlog.entity.UserCarbonLog; +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.time.LocalDateTime; +import java.util.List; + +public interface UserCarbonLogRepository extends JpaRepository { + + List findByUserIdAndRecordedAtBetween(Long userId, LocalDateTime start, LocalDateTime end); + + List findByUserIdAndRecordedAtBetweenAndIsFromChallengeTrue(Long userId, LocalDateTime start, + LocalDateTime end); + + void deleteByUserId(Long userId); + +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/repository/UserChallengeRepository.java b/server/src/main/java/com/soopgyeol/api/repository/UserChallengeRepository.java new file mode 100644 index 0000000..91c1288 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/UserChallengeRepository.java @@ -0,0 +1,17 @@ +package com.soopgyeol.api.repository; + +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.domain.userChallenge.entity.UserChallenge; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserChallengeRepository extends JpaRepository { + Optional findByUserAndDailyChallenge(User user, DailyChallenge dailyChallenge); + + List findAllByUserIdOrderByDailyChallengeCreatedAtDesc(Long userId); + + void deleteByUser(User user); +} diff --git a/server/src/main/java/com/soopgyeol/api/repository/UserRepository.java b/server/src/main/java/com/soopgyeol/api/repository/UserRepository.java new file mode 100644 index 0000000..d240ee3 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/repository/UserRepository.java @@ -0,0 +1,17 @@ +package com.soopgyeol.api.repository; + + +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findBySocialIdAndProvider(String socialId, SocialLoginType provider); + + Optional findByEmail(String email); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/UserService.java b/server/src/main/java/com/soopgyeol/api/service/UserService.java new file mode 100644 index 0000000..5e0e5da --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/UserService.java @@ -0,0 +1,49 @@ +package com.soopgyeol.api.service; + +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.UserRepository; +import com.soopgyeol.api.repository.StageRepository; +import com.soopgyeol.api.repository.InventoryRepository; +import com.soopgyeol.api.repository.UserChallengeRepository; +import com.soopgyeol.api.repository.UserCarbonLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + + private final UserRepository userRepository; + private final StageRepository stageRepository; + private final InventoryRepository inventoryRepository; + private final UserChallengeRepository userChallengeRepository; + private final UserCarbonLogRepository userCarbonLogRepository; + + public String updateNickname(Long userId, String nickname) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("유저를 찾을 수 없습니다.")); + + user.setNickname(nickname); + userRepository.save(user); + return nickname; + } + + @Transactional(readOnly = true) + public User getUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found")); + } + + public void deleteUser(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("유저를 찾을 수 없습니다.")); + stageRepository.deleteByUser(user); + inventoryRepository.deleteByUser(user); + userChallengeRepository.deleteByUser(user); + userCarbonLogRepository.deleteByUserId(userId); + userRepository.delete(user); + } + +} diff --git a/server/src/main/java/com/soopgyeol/api/service/auth/GoogleOauth.java b/server/src/main/java/com/soopgyeol/api/service/auth/GoogleOauth.java new file mode 100644 index 0000000..6b8610d --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/auth/GoogleOauth.java @@ -0,0 +1,110 @@ +package com.soopgyeol.api.service.auth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.dto.oauth.SocialUserInfo; +import io.github.cdimascio.dotenv.Dotenv; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + + +@Component +@AllArgsConstructor +public class GoogleOauth implements SocialOauth { + + private final RestTemplate rest = new RestTemplate(); + private final ObjectMapper om = new ObjectMapper(); + + private final String clientId; + private final String clientSecret; + + @Value("${oauth.google.redirect-uri}") + private String redirectUri; + + + @Autowired + public GoogleOauth(Dotenv dotenv) { + this.clientId = dotenv.get("GOOGLE_CLIENT_ID"); + this.clientSecret = dotenv.get("GOOGLE_CLIENT_SECRET"); + //this.redirectUri = dotenv.get("GOOGLE_REDIRECT_URI"); + + } + @Override + public SocialLoginType type() { + return SocialLoginType.GOOGLE; + } + + @Override + public String getOauthRedirectURL() { + return UriComponentsBuilder + .fromUriString("https://accounts.google.com/o/oauth2/v2/auth") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", "openid email profile") + .build() + .encode() + .toUriString(); + } + + @Override + public String requestAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("code", code); + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("redirect_uri", redirectUri); + + + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + String tokenJson = rest.postForObject( + "https://oauth2.googleapis.com/token", + request, + String.class + ); + return tokenJson; + } catch (HttpClientErrorException ex) { + System.out.println("❌ token 요청 실패: " + ex.getResponseBodyAsString()); + throw ex; + } + } + + + @Override + public SocialUserInfo getUserInfo(String accessToken, String ignore) throws IOException { + HttpHeaders h = new HttpHeaders(); + h.setBearerAuth(accessToken); + + String json = rest.exchange( + "https://openidconnect.googleapis.com/v1/userinfo", + HttpMethod.GET, + new HttpEntity<>(h), + String.class + ).getBody(); + + JsonNode n = om.readTree(json); + return new SocialUserInfo( + n.get("sub").asText(), + n.get("email").asText(), + n.get("name").asText() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/auth/KakaoOauth.java b/server/src/main/java/com/soopgyeol/api/service/auth/KakaoOauth.java new file mode 100644 index 0000000..ba29022 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/auth/KakaoOauth.java @@ -0,0 +1,88 @@ +package com.soopgyeol.api.service.auth; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.dto.oauth.SocialUserInfo; +import io.github.cdimascio.dotenv.Dotenv; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class KakaoOauth implements SocialOauth { + + private final RestTemplate rest = new RestTemplate(); + private final ObjectMapper om = new ObjectMapper(); + private final Dotenv dotenv = Dotenv.load(); + + private final String clientId = dotenv.get("KAKAO_CLIENT_ID"); + + //private final String redirectUri = dotenv.get("KAKAO_REDIRECT_URI"); + @Value("${oauth.kakao.redirect-uri}") + private String redirectUri; + private final String clientSecret = dotenv.get("KAKAO_CLIENT_SECRET"); // optional + + @Override + public SocialLoginType type() { + return SocialLoginType.KAKAO; + } + + @Override + public String getOauthRedirectURL() { + return UriComponentsBuilder.fromUriString("https://kauth.kakao.com/oauth/authorize") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("response_type", "code") + .queryParam("scope", "profile_nickname account_email") + .build() + .toUriString(); + } + + @Override + public String requestAccessToken(String code) { + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("code", code); + body.add("redirect_uri", redirectUri); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + return rest.postForObject( + "https://kauth.kakao.com/oauth/token", + new HttpEntity<>(body, headers), + String.class + ); + } + + @Override + public SocialUserInfo getUserInfo(String accessToken, String ignore) throws IOException { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + String json = rest.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ).getBody(); + + JsonNode node = om.readTree(json); + return new SocialUserInfo( + String.valueOf(node.get("id").asLong()), + node.at("/kakao_account/email").asText(), + node.at("/kakao_account/profile/nickname").asText() + ); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/auth/OAuthService.java b/server/src/main/java/com/soopgyeol/api/service/auth/OAuthService.java new file mode 100644 index 0000000..081e06e --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/auth/OAuthService.java @@ -0,0 +1,9 @@ +package com.soopgyeol.api.service.auth; + +import com.soopgyeol.api.dto.oauth.OAuthLoginRequest; +import com.soopgyeol.api.dto.oauth.OAuthLoginResponse; + + +public interface OAuthService { + OAuthLoginResponse login(OAuthLoginRequest request); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/auth/OAuthServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/auth/OAuthServiceImpl.java new file mode 100644 index 0000000..e428ad4 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/auth/OAuthServiceImpl.java @@ -0,0 +1,131 @@ +package com.soopgyeol.api.service.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.user.Role; +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.dto.oauth.*; +import com.soopgyeol.api.repository.UserRepository; +import com.soopgyeol.api.service.jwt.JwtProvider; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.*; + +@Service +public class OAuthServiceImpl implements OAuthService { + + + private final Map oauthMap = new EnumMap<>(SocialLoginType.class); + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final ObjectMapper mapper = new ObjectMapper(); + + private final Set usedCodes = ConcurrentHashMap.newKeySet(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + + public OAuthServiceImpl(List oauthList, + UserRepository userRepository, + PasswordEncoder passwordEncoder, + JwtProvider jwtProvider) { + + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtProvider = jwtProvider; + + oauthList.forEach(o -> oauthMap.put(o.type(), o)); + } + + @PostConstruct + void logProvider() { + System.out.println(">>> 활성 SocialOauth = " + oauthMap.keySet()); // [GOOGLE, KAKAO] + } + + + @Override + public OAuthLoginResponse login(OAuthLoginRequest request) { + + + + if (!usedCodes.add(request.getCode())) { + System.out.println("### DUPLICATE CODE BLOCKED: " + request.getCode()); + throw new IllegalStateException("이미 사용한 authorization_code"); + } + + scheduler.schedule(() -> usedCodes.remove(request.getCode()), + 10, TimeUnit.MINUTES); + + + SocialLoginType type; + try { + type = SocialLoginType.valueOf(request.getProvider().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("지원하지 않는 provider: " + request.getProvider()); + } + + + SocialOauth oauth = oauthMap.get(type); + if (oauth == null) + throw new IllegalStateException("provider 매핑 실패: " + type); + + + String tokenJson = oauth.requestAccessToken(request.getCode()); +// System.out.println(">>> tokenJson = " + tokenJson); + + + String accessToken; + String idToken = null; + try { + if (type == SocialLoginType.GOOGLE) { + GoogleTokenResponse res = mapper.readValue(tokenJson, GoogleTokenResponse.class); + accessToken = res.getAccessToken(); + idToken = res.getIdToken(); + } else { // kakao + KakaoTokenResponse res = mapper.readValue(tokenJson, KakaoTokenResponse.class); + accessToken = res.getAccessToken(); + } + } catch (IOException e) { + throw new IllegalStateException("토큰 JSON 파싱 실패", e); + } + + + SocialUserInfo info; + try { + info = oauth.getUserInfo(accessToken, idToken); + } catch (IOException e) { + throw new IllegalStateException("사용자 정보 조회 실패", e); + } + + + User user = userRepository + .findBySocialIdAndProvider(info.getSocialId(), type) + .orElseGet(() -> userRepository.saveAndFlush( + User.builder() + .email(info.getEmail()) + .nickname(info.getNickname()) + .provider(type) + .socialId(info.getSocialId()) + .password(passwordEncoder.encode("dummy")) + .role(Role.USER) + .build())); + + + String jwt = jwtProvider.createToken(user.getId(), user.getRole()); + + + return OAuthLoginResponse.builder() + .jwtToken(jwt) + .isNewUser(user.getCreatedAt().equals(user.getUpdatedAt())) + .build(); + } + +} diff --git a/server/src/main/java/com/soopgyeol/api/service/auth/SocialOauth.java b/server/src/main/java/com/soopgyeol/api/service/auth/SocialOauth.java new file mode 100644 index 0000000..e04cd94 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/auth/SocialOauth.java @@ -0,0 +1,13 @@ +package com.soopgyeol.api.service.auth; + +import com.soopgyeol.api.domain.user.SocialLoginType; +import com.soopgyeol.api.dto.oauth.SocialUserInfo; + +import java.io.IOException; + +public interface SocialOauth { + SocialLoginType type(); // 구글 or 카카오 + String getOauthRedirectURL(); + String requestAccessToken(String code); + SocialUserInfo getUserInfo(String accessToken, String idToken) throws IOException;} + diff --git a/server/src/main/java/com/soopgyeol/api/service/buy/BuyService.java b/server/src/main/java/com/soopgyeol/api/service/buy/BuyService.java new file mode 100644 index 0000000..b3a3dff --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/buy/BuyService.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.service.buy; + +import com.soopgyeol.api.domain.buy.dto.BuyResult; + +public interface BuyService { + BuyResult buyItem(Long userId, Long itemId); // 반드시 BuyResult로 변경 +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/buy/BuyServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/buy/BuyServiceImpl.java new file mode 100644 index 0000000..8219e9a --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/buy/BuyServiceImpl.java @@ -0,0 +1,71 @@ +package com.soopgyeol.api.service.buy; + +import com.soopgyeol.api.domain.buy.dto.BuyResult; +import com.soopgyeol.api.domain.buy.entity.Purchase; +import com.soopgyeol.api.domain.item.entity.Item; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.ItemRepository; +import com.soopgyeol.api.repository.PurchaseRepository; +import com.soopgyeol.api.repository.UserRepository; +import com.soopgyeol.api.common.exception.InsufficientBalanceException; +import com.soopgyeol.api.common.exception.ItemAlreadyOwnedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BuyServiceImpl implements BuyService { + + private final ItemRepository itemRepository; + private final PurchaseRepository purchaseRepository; + private final UserRepository userRepository; + + public BuyServiceImpl(ItemRepository itemRepository, + PurchaseRepository purchaseRepository, + UserRepository userRepository) { + this.itemRepository = itemRepository; + this.purchaseRepository = purchaseRepository; + this.userRepository = userRepository; + } + + @Transactional + @Override + public BuyResult buyItem(Long userId, Long itemId) { + + // 1. 구매 가능 여부 (사용자 금액과 아이템 금액 비교) + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("해당 유저가 존재하지 않습니다.")); + + Item item = itemRepository.findById(itemId) + .orElseThrow(() -> new IllegalArgumentException("아이템이 존재하지 않습니다.")); + + if (user.getMoneyBalance() < item.getPrice()) { + throw new InsufficientBalanceException("보유 금액이 부족합니다."); + } + + // 2. 보유 중복 여부 + boolean alreadyOwned = purchaseRepository.existsByUserAndItem(user, item); + if (alreadyOwned) { + throw new ItemAlreadyOwnedException("이미 보유한 아이템입니다."); + } + + // 3. 금액 차감 + int money = item.getPrice(); + user.subMoney(money); + + // 4. 인벤토리 저장 + Purchase purchase = Purchase.builder() + .user(user) + .item(item) + .itemMoney(item.getPrice()) + .build(); + + purchaseRepository.save(purchase); + + return BuyResult.builder() + .itemId(item.getId()) + .itemName(item.getName()) + .itemPrice(item.getPrice()) + .userMoneyBalance(user.getMoneyBalance()) + .build(); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisService.java b/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisService.java new file mode 100644 index 0000000..87579b7 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisService.java @@ -0,0 +1,10 @@ +package com.soopgyeol.api.service.carbon; + +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.enums.Category; + +public interface CarbonAnalysisService { + CarbonAnalysisResponse analyzeAndSave(String userInput); + + CarbonAnalysisResponse analyzeByKeyword(String keyword, Category category, Long challengeId); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisServiceImpl.java new file mode 100644 index 0000000..af48f9e --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/carbon/CarbonAnalysisServiceImpl.java @@ -0,0 +1,84 @@ +package com.soopgyeol.api.service.carbon; + +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.carbon.entity.CarbonItem; +import com.soopgyeol.api.domain.enums.Category; +import com.soopgyeol.api.repository.CarbonItemRepository; +import com.soopgyeol.api.service.gpt.AIChallengeSearchService; +import com.soopgyeol.api.service.gpt.OpenAiService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class CarbonAnalysisServiceImpl implements CarbonAnalysisService { + private final OpenAiService openAiService; + private final CarbonItemRepository carbonItemRepository; + private final AIChallengeSearchService aiChallengeSearchService; + + @Override + public CarbonAnalysisResponse analyzeAndSave(String userInput) { + // GPT 분석 요청 + CarbonAnalysisResponse analysis = openAiService.analyzeCarbon(userInput); + + analysis.setCategoryKorean(analysis.getCategory().getDescription()); + + CarbonItem carbonItem = CarbonItem.builder() + .name(analysis.getName()) + .category(analysis.getCategory()) + .carbonValue((float) analysis.getCarbonGrams()) + .growthPoint(analysis.getGrowthPoint()) + .explanation(analysis.getExplanation()) + .createdAt(LocalDateTime.now()) + .build(); + + CarbonItem savedItem = carbonItemRepository.save(carbonItem); + + return CarbonAnalysisResponse.builder() + .carbonItemId(savedItem.getId()) + .name(savedItem.getName()) + .category(savedItem.getCategory()) + .categoryKorean(analysis.getCategoryKorean()) + .carbonGrams(savedItem.getCarbonValue()) + .growthPoint(savedItem.getGrowthPoint()) + .explanation(savedItem.getExplanation()) + .categoryImageUrl(savedItem.getCategory().getImageUrl()) + .build(); + } + + @Override + public CarbonAnalysisResponse analyzeByKeyword(String keyword, Category category, Long challengeId) { + // 1. GPT로 분석 요청 (챌린지에서 제공한 키워드 기반) + CarbonAnalysisResponse analysis = aiChallengeSearchService.analyzeWithFixedCategory(keyword, category); + + + analysis.setCategoryKorean(analysis.getCategory().getDescription()); + + + CarbonItem carbonItem = CarbonItem.builder() + .name(analysis.getName()) + .category(analysis.getCategory()) + .carbonValue((float) analysis.getCarbonGrams()) + .growthPoint(analysis.getGrowthPoint()) + .explanation(analysis.getExplanation()) + .createdAt(LocalDateTime.now()) + .build(); + + CarbonItem savedItem = carbonItemRepository.save(carbonItem); + + + return CarbonAnalysisResponse.builder() + .carbonItemId(savedItem.getId()) // 저장된 ID 포함 + .name(savedItem.getName()) + .category(savedItem.getCategory()) + .categoryKorean(analysis.getCategoryKorean()) + .carbonGrams(savedItem.getCarbonValue()) + .growthPoint(savedItem.getGrowthPoint()) + .explanation(savedItem.getExplanation()) + .categoryImageUrl(savedItem.getCategory().getImageUrl()) + .challengeId(challengeId) + .build(); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogService.java b/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogService.java new file mode 100644 index 0000000..4a79766 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogService.java @@ -0,0 +1,17 @@ +package com.soopgyeol.api.service.carbonlog; + +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogRequest; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogResponse; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogSummaryResponse; + +import java.time.LocalDate; +import java.util.List; + +public interface UserCarbonLogService { + void saveCarbonLog(UserCarbonLogRequest request); + + UserCarbonLogSummaryResponse getLogsByUserIdAndDate(Long userId, LocalDate date); + + // 챌린지 로그만 조회 + UserCarbonLogSummaryResponse getChallengeLogsByUserIdAndDate(Long userId, LocalDate date); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogServiceImpl.java new file mode 100644 index 0000000..7f951a5 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/carbonlog/UserCarbonLogServiceImpl.java @@ -0,0 +1,144 @@ +package com.soopgyeol.api.service.carbonlog; + +import com.soopgyeol.api.domain.carbon.entity.CarbonItem; +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.domain.userChallenge.entity.UserChallenge; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogRequest; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogResponse; +import com.soopgyeol.api.domain.usercarbonlog.dto.UserCarbonLogSummaryResponse; +import com.soopgyeol.api.domain.usercarbonlog.entity.UserCarbonLog; +import com.soopgyeol.api.repository.*; +import com.soopgyeol.api.service.stage.TreeStageService; +import com.soopgyeol.api.service.hero.HeroStageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + + + +@Service +@RequiredArgsConstructor +public class UserCarbonLogServiceImpl implements UserCarbonLogService { + private final UserRepository userRepository; + private final CarbonItemRepository carbonItemRepository; + private final UserCarbonLogRepository carbonLogRepository; + private final TreeStageService treeStageService; + private final HeroStageService heroStageService; + private final DailyChallengeRepository dailyChallengeRepository; + private final UserChallengeRepository userChallengeRepository; + + @Override + @Transactional + public void saveCarbonLog(UserCarbonLogRequest request) { + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("유저가 존재하지 않습니다.")); + + CarbonItem carbonItem = carbonItemRepository.findById(request.getCarbonItemId()) + .orElseThrow(() -> new IllegalArgumentException("탄소 품목이 존재하지 않습니다.")); + + int quantity = request.getQuantity(); + float totalCarbon = carbonItem.getCarbonValue() * quantity; + int totalGrowthPoint = carbonItem.getGrowthPoint() * quantity; + + // User 엔티티의 growthPoint 갱신 + user.setGrowthPoint(user.getGrowthPoint() + totalGrowthPoint); + userRepository.save(user); + + // 단계 최신화 추가 + treeStageService.updateTreeStageByGrowth(user.getId()); + heroStageService.updateHeroStageByGrowth(user.getId()); + + + boolean isFromChallenge = false; + if (request.getDailyChallengeId() != null){ + isFromChallenge = true; + + // 챌린지 정보 조회 + DailyChallenge challenge = dailyChallengeRepository.findById(request.getDailyChallengeId()) + .orElseThrow(() -> new IllegalArgumentException("챌린지 정보가 존재하지 않습니다.")); + + // 유저 챌린지 조회 + UserChallenge userChallenge = userChallengeRepository.findByUserAndDailyChallenge(user, challenge) + .orElseThrow(() -> new IllegalArgumentException("해당 유저의 챌린지 참여 정보가 없습니다.")); + + // 진행도 업데이트 + userChallenge.increaseProgress(quantity); + + + userChallengeRepository.save(userChallenge); + } + + + UserCarbonLog log = UserCarbonLog.builder() + .user(user) + .carbonItem(carbonItem) + .quantity(request.getQuantity()) + .calculatedCarbon(totalCarbon) + .growthPoint(totalGrowthPoint) + .recordedAt(LocalDateTime.now()) + .isFromChallenge(isFromChallenge) + .build(); + + carbonLogRepository.save(log); + } + + public UserCarbonLogSummaryResponse getLogsByUserIdAndDate(Long userId, LocalDate date) { + // 날짜 기준으로 시작/끝 시간 + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.atTime(LocalTime.MAX); + + + + List logs = carbonLogRepository.findByUserIdAndRecordedAtBetween(userId, startOfDay, + endOfDay); + + int totalGrowthPoint = logs.stream() + .mapToInt(UserCarbonLog::getGrowthPoint) + .sum(); + + List logDtos = logs.stream() + .map(log -> UserCarbonLogResponse.builder() + .product(log.getCarbonItem().getName()) + .growthPoint(log.getGrowthPoint()) + .build()) + .toList(); + + return UserCarbonLogSummaryResponse.builder() + .logs(logDtos) + .totalGrowthPoint(totalGrowthPoint) + .build(); + } + + public UserCarbonLogSummaryResponse getChallengeLogsByUserIdAndDate(Long userId, LocalDate date) { + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(LocalTime.MAX); + + List logs = carbonLogRepository.findByUserIdAndRecordedAtBetweenAndIsFromChallengeTrue( + userId, start, end + ); + + int totalGrowthPoint = logs.stream() + .mapToInt(UserCarbonLog::getGrowthPoint) + .sum(); + + List logDtos = logs.stream() + .map(log -> UserCarbonLogResponse.builder() + .product(log.getCarbonItem().getName()) + .growthPoint(log.getGrowthPoint()) + .build()) + .toList(); + + return UserCarbonLogSummaryResponse.builder() + .logs(logDtos) + .totalGrowthPoint(totalGrowthPoint) + .build(); + } + + +} diff --git a/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeService.java b/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeService.java new file mode 100644 index 0000000..0cb7d70 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeService.java @@ -0,0 +1,10 @@ +package com.soopgyeol.api.service.dailychallenge; + +import com.soopgyeol.api.domain.challenge.dto.ChallengeCompleteResponse; +import com.soopgyeol.api.domain.challenge.dto.ChallengeTodayResponse; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserChallengeService { + ChallengeTodayResponse getTodayChallengeForUser(Long userId); + ChallengeCompleteResponse completeChallenge(Long userId, Long dailyChallengeId); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeServiceImpl.java new file mode 100644 index 0000000..df996be --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/dailychallenge/UserChallengeServiceImpl.java @@ -0,0 +1,122 @@ +package com.soopgyeol.api.service.dailychallenge; + +import com.soopgyeol.api.domain.challenge.dto.AIChallengePromptResult; +import com.soopgyeol.api.domain.challenge.dto.ChallengeCompleteResponse; +import com.soopgyeol.api.domain.challenge.dto.ChallengeTodayResponse; +import com.soopgyeol.api.domain.challenge.dto.UserChallengeHistoryDto; +import com.soopgyeol.api.domain.challenge.entity.DailyChallenge; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.domain.userChallenge.entity.UserChallenge; +import com.soopgyeol.api.repository.DailyChallengeRepository; +import com.soopgyeol.api.repository.UserChallengeRepository; +import com.soopgyeol.api.repository.UserRepository; +import com.soopgyeol.api.service.gpt.AIChallengePromptService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserChallengeServiceImpl implements UserChallengeService { + + private final DailyChallengeRepository dailyChallengeRepository; + private final UserChallengeRepository userChallengeRepository; + private final AIChallengePromptService aiChallengePromptService; + private final UserRepository userRepository; + + @Override + @Transactional + public ChallengeTodayResponse getTodayChallengeForUser(Long userId) { + // 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("해당 유저가 존재하지 않습니다.")); + + // 오늘의 챌린지 조회 or GPT로 생성 + DailyChallenge dailyChallenge = dailyChallengeRepository.findByIsActiveTrue() + .orElseGet(() -> { + // 이전 챌린지 비활성화 + dailyChallengeRepository.deactivateAll(); + + // GPT로 새로운 챌린지 생성 + AIChallengePromptResult gptResponse = aiChallengePromptService.generateChallenge(); + DailyChallenge newChallenge = DailyChallenge.builder() + .title(gptResponse.getTitle()) + .goalCount(gptResponse.getGoalCount()) + .rewardMoney(gptResponse.getRewardMoney()) + .carbonKeyword(gptResponse.getCarbonKeyword()) + .category(gptResponse.getCategory()) + .isActive(true) + .build(); + return dailyChallengeRepository.save(newChallenge); + }); + + // 유저 챌린지 참여 기록 조회 or 생성 + UserChallenge userChallenge = userChallengeRepository.findByUserAndDailyChallenge(user, dailyChallenge) + .orElseGet(() -> { + UserChallenge newChallenge = UserChallenge.builder() + .user(user) + .dailyChallenge(dailyChallenge) + .progressCount(0) + .isCompleted(false) + .build(); + return userChallengeRepository.save(newChallenge); + }); + + // DTO로 반환 + return ChallengeTodayResponse.builder() + .challengeId(dailyChallenge.getId()) + .title(dailyChallenge.getTitle()) + .goalCount(dailyChallenge.getGoalCount()) + .rewardMoney(dailyChallenge.getRewardMoney()) + .carbonKeyword(dailyChallenge.getCarbonKeyword()) + .category(dailyChallenge.getCategory()) + .progressCount(userChallenge.getProgressCount()) + .isCompleted(userChallenge.isCompleted()) + .categoryImageUrl(dailyChallenge.getCategory().getImageUrl()) + .build(); + } + + @Override + @Transactional + public ChallengeCompleteResponse completeChallenge(Long userId, Long dailyChallengeId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("유저가 존재하지 않습니다.")); + + DailyChallenge dailyChallenge = dailyChallengeRepository.findById(dailyChallengeId) + .orElseThrow(() -> new IllegalArgumentException("챌린지가 존재하지 않습니다.")); + + UserChallenge userChallenge = userChallengeRepository.findByUserAndDailyChallenge(user, dailyChallenge) + .orElseThrow(() -> new IllegalArgumentException("해당 유저의 챌린지 기록이 없습니다.")); + + if (!userChallenge.isCompleted()) { + throw new IllegalArgumentException("아직 챌린지를 완료하지 않았습니다."); + } + + if (userChallenge.isRewardReceived()) { + throw new IllegalArgumentException("이미 보상을 받은 챌린지입니다."); + } + + int reward = dailyChallenge.getRewardMoney(); + user.addMoney(reward); + userChallenge.setRewardReceived(true); + + return new ChallengeCompleteResponse(reward, user.getMoneyBalance()); + } + + @Transactional + public List getUserChallengeHistory(Long userId) { + List userChallenges = userChallengeRepository.findAllByUserIdOrderByDailyChallengeCreatedAtDesc(userId); + + return userChallenges.stream() + .map(uc -> UserChallengeHistoryDto.builder() + .title(uc.getDailyChallenge().getTitle()) + .createdAt(uc.getDailyChallenge().getCreatedAt().toLocalDate()) // 여기 변환 + .isCompleted(uc.isCompleted()) + .build()) + .toList(); + + } + +} diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptService.java b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptService.java new file mode 100644 index 0000000..81da166 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptService.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.service.gpt; + +import com.soopgyeol.api.domain.challenge.dto.AIChallengePromptResult; + +public interface AIChallengePromptService { + AIChallengePromptResult generateChallenge(); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptServiceImpl.java new file mode 100644 index 0000000..4dc41b7 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengePromptServiceImpl.java @@ -0,0 +1,113 @@ +package com.soopgyeol.api.service.gpt; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.challenge.dto.AIChallengePromptResult; +import com.soopgyeol.api.domain.enums.Category; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AIChallengePromptServiceImpl implements AIChallengePromptService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${OPENAI_API_KEY}") + private String apiKey; + + private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + + @Override + public AIChallengePromptResult generateChallenge() { + String systemPrompt = """ + 너는 탄소 절감 챌린지를 추천해주는 시스템이야. + 오늘 사용자에게 줄 수 있는 탄소 절감 챌린지를 1개 추천해줘. + + - 응답은 JSON 형식으로만 해 + - challengeTitle은 15자 이내의 짧고 명확한 챌린지 제목 + - rewardMoney는 너가 판단하기에 챌린지 난이도에 따라 5 ~ 20으로 줘! + - carbonKeyword는 기존 탄소 소비 분석 기능에서 사용하는 실생활 소비 키워드여야 해 (예: 다회용컵, 채식, 대중교통, 전기차, 에너지 절약, 음식물 쓰레기 줄이기 등) + - category는 FOOD, TRANSPORTATION, CLOTHING, HOUSING_ENERGY, RECYCLE_WASTE, LIFESTYLE_CONSUMPTION, ETC 중 하나 + { + "challengeTitle": (챌린지명), + "goalCount": (목표횟수), + "rewardMoney": (보상 돈), + "carbonKeyword": (소비 키워드), + "category": (위 category 중 하나), + } + """; + + List messages = List.of( + buildMessage("system", systemPrompt), + buildMessage("user", "오늘의 챌린지를 생성해줘") + + ); + + String requestBody = String.format(""" + { + "model": "gpt-3.5-turbo", + "messages": %s, + "temperature": 0.3 + } + """, toJson(messages)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange( + OPENAI_API_URL, + HttpMethod.POST, + entity, + String.class + ); + + try { + JsonNode root = objectMapper.readTree(response.getBody()); + String content = root + .path("choices").get(0) + .path("message") + .path("content") + .asText(); + + JsonNode json = objectMapper.readTree(content); + + System.out.println("GPT 응답 content: " + content); + + return AIChallengePromptResult.builder() + .title(json.get("challengeTitle").asText()) + .goalCount(json.get("goalCount").asInt()) + .rewardMoney(json.get("rewardMoney").asInt()) + .carbonKeyword(json.get("carbonKeyword").asText()) + .category(Category.fromString(json.get("category").asText())) + .build(); + } catch (Exception e){ + throw new RuntimeException("GPT 챌린지 응답 파싱 실패", e); + } + } + + private Object buildMessage(String role, String content) { + return new java.util.HashMap<>() {{ + put("role", role); + put("content", content); + }}; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException("JSON 직렬화 실패", e); + } + } +} + diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchService.java b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchService.java new file mode 100644 index 0000000..b5433e0 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchService.java @@ -0,0 +1,8 @@ +package com.soopgyeol.api.service.gpt; + +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.enums.Category; + +public interface AIChallengeSearchService { + CarbonAnalysisResponse analyzeWithFixedCategory(String keyword, Category category); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchServiceImpl.java new file mode 100644 index 0000000..e1de5fa --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/AIChallengeSearchServiceImpl.java @@ -0,0 +1,102 @@ +package com.soopgyeol.api.service.gpt; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.enums.Category; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AIChallengeSearchServiceImpl implements AIChallengeSearchService { + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${OPENAI_API_KEY}") + private String apiKey; + + private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + + @Override + public CarbonAnalysisResponse analyzeWithFixedCategory(String keyword, Category fixedCategory) { + String systemPrompt = """ + 너는 탄소 분석 시스템이야. 사용자의 입력을 받고 다음 형식으로 JSON으로 대답해. + 품목명과, 탄소량 설명은 반드시 한글로 대답해줘. + + "growthPoint"는 사용자의 해당 활동 또는 소비에 따라서 탄소가 절감되는 정도를 너가 판단해서 0~20점 사이로 점수를 줘. + ETC는 탄소량, growthPoint 모두 0점으로 줘. + + { + "name": (품목명, 한글), + "carbonGrams": (사용자의 입력에 따라 탄소 소비량을 분석해서 반환, 숫자, g 단위), + "growthPoint": (숫자, 정수 단위), + "explanation": (왜 이 탄소량이 나왔는지 설명. 30자 이내로 간결하게 작성) + } + """; + + List messages = List.of( + buildMessage("system", systemPrompt), + buildMessage("user", keyword) + ); + + String requestBody = String.format(""" + { + "model": "gpt-3.5-turbo", + "messages": %s, + "temperature": 0.2 + } + """, toJson(messages)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange( + OPENAI_API_URL, + HttpMethod.POST, + entity, + String.class + ); + + try { + JsonNode root = objectMapper.readTree(response.getBody()); + String content = root.path("choices").get(0).path("message").path("content").asText(); + JsonNode json = objectMapper.readTree(content); + + return CarbonAnalysisResponse.builder() + .name(json.get("name").asText()) + .carbonGrams(json.get("carbonGrams").asDouble()) + .growthPoint(json.get("growthPoint").asInt()) + .explanation(json.get("explanation").asText()) + .category(fixedCategory) // GPT로부터 받지 않고 직접 삽입 + .build(); + + } catch (Exception e) { + throw new RuntimeException("챌린지 GPT 응답 파싱 실패", e); + } + } + + private Object buildMessage(String role, String content) { + return new HashMap<>() {{ + put("role", role); + put("content", content); + }}; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException("JSON 직렬화 실패", e); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiService.java b/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiService.java new file mode 100644 index 0000000..7c45fe9 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiService.java @@ -0,0 +1,7 @@ +package com.soopgyeol.api.service.gpt; + +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; + +public interface OpenAiService { + CarbonAnalysisResponse analyzeCarbon(String userInput); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiServiceImpl.java new file mode 100644 index 0000000..1a08ad1 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/gpt/OpenAiServiceImpl.java @@ -0,0 +1,117 @@ +package com.soopgyeol.api.service.gpt; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.soopgyeol.api.domain.carbon.dto.CarbonAnalysisResponse; +import com.soopgyeol.api.domain.enums.Category; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OpenAiServiceImpl implements OpenAiService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + + @Value("${OPENAI_API_KEY}") + private String apiKey; + + private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + + @Override + public CarbonAnalysisResponse analyzeCarbon(String userInput) { + String systemPrompt = """ + 너는 탄소 분석 시스템이야. 사용자의 입력을 받고 다음 형식으로 JSON으로 대답해. + 품목명과, 탄소량 설명은 반드시 한글로 대답해줘. + + + FOOD, TRANSPORTATION, CLOTHING, HOUSING_ENERGY, RECYCLE_WASTE, LIFESTYLE_CONSUMPTION, ETC 중 하나 + + "growthPoint"는 사용자의 해당 활동 또는 소비에 따라서 탄소가 절감되는 정도를 너가 판단해서 0~20점 사이로 점수를 줘. + ETC는 탄소량, growthPoint 모두 0점으로 줘. + { "name": (제품명), + "category": (위 category 중 하나), + "carbonGrams": (사용자의 입력에 따라 탄소 소비량을 분석해서 반환, 숫자, g 단위), + "growthPoint": (숫자, 정수 단위) + "explanation": (왜 이 탄소량이 나왔는지 설명. 30자 이내로 간결하게 작성) + """; + + List messages = List.of( + buildMessage("system", systemPrompt), + buildMessage("user", userInput) + ); + + // 요청 바디 구성 + String requestBody = String.format(""" + { + "model": "gpt-3.5-turbo", + "messages": %s, + "temperature": 0.2 + } + """, toJson(messages)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(apiKey); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + // OpenAI API 호출 + ResponseEntity response = restTemplate.exchange( + OPENAI_API_URL, + HttpMethod.POST, + entity, + String.class + ); + + try { + JsonNode root = objectMapper.readTree(response.getBody()); + String content = root + .path("choices").get(0) + .path("message") + .path("content") + .asText(); + + JsonNode json = objectMapper.readTree(content); + + String name = json.get("name").asText(); + double carbonGrams = json.get("carbonGrams").asDouble(); + int growthPoint = json.get("growthPoint").asInt(); + String categoryStr = json.get("category").asText(); + String explanation = json.get("explanation").asText(); + + return CarbonAnalysisResponse.builder() + .name(name) + .category(Category.fromString(categoryStr)) + .carbonGrams(carbonGrams) + .growthPoint(growthPoint) + .explanation(explanation) + .build(); + + } catch (Exception e) { + throw new RuntimeException("GPT 응답 파싱 실패", e); + } + } + + private Object buildMessage(String role, String content) { + return new java.util.HashMap<>() {{ + put("role", role); + put("content", content); + }}; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException("JSON 직렬화 실패", e); + } + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageService.java b/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageService.java new file mode 100644 index 0000000..8f702b3 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageService.java @@ -0,0 +1,9 @@ +package com.soopgyeol.api.service.hero; + +import com.soopgyeol.api.domain.stage.dto.HeroStageResponse; + +public interface HeroStageService { + void updateHeroStageByGrowth(Long userId); + + HeroStageResponse getHeroStageMessage(Long userId); +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageServiceImpl.java new file mode 100644 index 0000000..34650f3 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/hero/HeroStageServiceImpl.java @@ -0,0 +1,79 @@ +package com.soopgyeol.api.service.hero; + +import com.soopgyeol.api.domain.stage.dto.HeroStageResponse; +import com.soopgyeol.api.domain.stage.entity.Stage; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.StageRepository; +import com.soopgyeol.api.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class HeroStageServiceImpl implements HeroStageService { + private final UserRepository userRepository; + private final StageRepository stageRepository; + + @Override + @Transactional + public void updateHeroStageByGrowth(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Stage userStage = stageRepository.findByUser(user) + .orElseGet(() -> stageRepository.save(Stage.builder() + .user(user) + .treeName("씨앗") + .treeUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/seed.png") + .heroName("Lv.1 새싹지기") + .heroUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/heroseed.png") + .build())); + + int growth = user.getGrowthPoint(); + String heroName; + String heroUrl; + if (growth <= 100) { + heroName = "Lv.1 새싹지기"; + heroUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/heroseed.png"; + } else if (growth <= 300) { + heroName = "Lv.2 줄임꾼"; + heroUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/herosappling.png"; + } else if (growth <= 700) { + heroName = "Lv.3 탐험가"; + heroUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/herolittletree.png"; + } else { + heroName = "Lv.4 지구지킴이"; + heroUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/herotree.png"; + } + + userStage.setHeroName(heroName); + userStage.setHeroUrl(heroUrl); + stageRepository.save(userStage); + } + + @Override + @Transactional + public HeroStageResponse getHeroStageMessage(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Stage userStage = stageRepository.findByUser(user) + .orElseGet(() -> stageRepository.save(Stage.builder() + .user(user) + .treeName("씨앗") + .treeUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/seed.png") + .heroName("Lv.1 새싹지기") + .heroUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/heroseed.png") + .build())); + + String heroName = userStage.getHeroName(); + String heroUrl = userStage.getHeroUrl(); + if (heroName == null) { + throw new IllegalArgumentException("사용자의 영웅 단계 정보가 없습니다"); + } + + return HeroStageResponse.builder() + .heroName(heroName) + .heroUrl(heroUrl) + .build(); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/item/ItemService.java b/server/src/main/java/com/soopgyeol/api/service/item/ItemService.java new file mode 100644 index 0000000..93cc2e4 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/item/ItemService.java @@ -0,0 +1,18 @@ +package com.soopgyeol.api.service.item; + +import com.soopgyeol.api.domain.enums.ItemCategory; +import com.soopgyeol.api.domain.item.dto.ItemResponse; +import com.soopgyeol.api.domain.item.dto.DisplayResponse; +import java.util.List; + +public interface ItemService { + List getItemsByUserIdAndCategory(Long userId, ItemCategory category); + + List getDisplayedItemsByUserId(Long userId); + + List getBuyedItemsByUserId(Long userId); + + List getBuyedItemsByUserIdAndCategory(Long userId, ItemCategory category); + + DisplayResponse toggleDisplay(Long userId, Long itemId); +} \ No newline at end of file diff --git a/server/src/main/java/com/soopgyeol/api/service/item/ItemServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/item/ItemServiceImpl.java new file mode 100644 index 0000000..d0deb0f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/item/ItemServiceImpl.java @@ -0,0 +1,153 @@ +package com.soopgyeol.api.service.item; + +import com.soopgyeol.api.domain.enums.ItemCategory; +import com.soopgyeol.api.domain.item.dto.ItemResponse; +import com.soopgyeol.api.domain.item.dto.DisplayResponse; +import com.soopgyeol.api.domain.item.entity.Inventory; +import com.soopgyeol.api.domain.item.entity.Item; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.InventoryRepository; +import com.soopgyeol.api.repository.ItemRepository; +import com.soopgyeol.api.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ItemServiceImpl implements ItemService { + private final UserRepository userRepository; + private final InventoryRepository inventoryRepository; + private final ItemRepository itemRepository; + + @Override + public List getItemsByUserIdAndCategory(Long userId, ItemCategory category) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + List items = itemRepository.findByCategory(category); + List inventories = inventoryRepository.findByUser(user); + return items.stream().map(item -> { + Inventory inventory = inventories.stream() + .filter(inv -> inv.getItem().getId().equals(item.getId())) + .findFirst() + .orElse(null); + boolean bought = inventory != null && inventory.isBuyed(); + boolean displayed = inventory != null && inventory.isDisplayed(); + boolean available = user.getMoneyBalance() >= item.getPrice(); + return ItemResponse.builder() + .id(item.getId()) + .name(item.getName()) + .price(item.getPrice()) + .url(item.getUrl()) + .category(item.getCategory()) + .display(displayed) + .available(available) + .isBuyed(bought) + .build(); + }).collect(Collectors.toList()); + } + + @Override + public List getDisplayedItemsByUserId(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + return inventoryRepository.findByUserAndIsDisplayedTrue(user).stream() + .map(inventory -> toDto(inventory, user.getMoneyBalance())) + .collect(Collectors.toList()); + } + + @Override + public List getBuyedItemsByUserId(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + return inventoryRepository.findByUserAndIsBuyedTrue(user).stream() + .map(inventory -> { + Item item = inventory.getItem(); + boolean displayed = inventory.isDisplayed(); + boolean available = user.getMoneyBalance() >= item.getPrice(); + return ItemResponse.builder() + .id(item.getId()) + .name(item.getName()) + .price(item.getPrice()) + .url(item.getUrl()) + .category(item.getCategory()) + .display(displayed) + .available(available) + .isBuyed(true) + .build(); + }) + .collect(Collectors.toList()); + } + + @Override + public List getBuyedItemsByUserIdAndCategory(Long userId, ItemCategory category) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + return inventoryRepository.findByUserAndIsBuyedTrue(user).stream() + .filter(inventory -> inventory.getItem().getCategory() == category) + .map(inventory -> { + Item item = inventory.getItem(); + boolean displayed = inventory.isDisplayed(); + boolean available = user.getMoneyBalance() >= item.getPrice(); + return ItemResponse.builder() + .id(item.getId()) + .name(item.getName()) + .price(item.getPrice()) + .url(item.getUrl()) + .category(item.getCategory()) + .display(displayed) + .available(available) + .isBuyed(true) + .build(); + }) + .collect(Collectors.toList()); + } + + @Override + public DisplayResponse toggleDisplay(Long userId, Long itemId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Item item = itemRepository.findById(itemId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이템입니다.")); + Inventory inventory = inventoryRepository.findByUserAndItem(user, item).stream() + .filter(Inventory::isBuyed) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("해당 아이템을 보유하고 있지 않습니다.")); + boolean newDisplay = !inventory.isDisplayed(); + + if (!inventory.isDisplayed() && newDisplay) { + List displayedInventories = inventoryRepository.findByUserAndIsDisplayedTrue(user); + boolean sameCategoryDisplayed = displayedInventories.stream() + .anyMatch( + inv -> inv.getItem().getCategory() == item.getCategory() && !inv.getItem().getId().equals(item.getId())); + if (sameCategoryDisplayed) { + throw new IllegalStateException("같은 카테고리 내 다른 아이템이 이미 전시중입니다!"); + } + } + + inventory.setDisplayed(newDisplay); + inventoryRepository.save(inventory); + return DisplayResponse.builder() + .itemId(itemId) + .display(newDisplay) + .message("전시 상태가 " + (newDisplay ? "전시됨" : "전시 해제됨")) + .build(); + } + + private ItemResponse toDto(Inventory inventory, int userMoneyBalance) { + Item item = inventory.getItem(); + boolean available = userMoneyBalance >= item.getPrice(); + return ItemResponse.builder() + .id(item.getId()) + .name(item.getName()) + .price(item.getPrice()) + .url(item.getUrl()) + .category(item.getCategory()) + .display(inventory.isDisplayed()) + .available(available) + .isBuyed(inventory.isBuyed()) + .build(); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/jwt/JwtAuthFilter.java b/server/src/main/java/com/soopgyeol/api/service/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..c0dcc80 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/jwt/JwtAuthFilter.java @@ -0,0 +1,118 @@ +package com.soopgyeol.api.service.jwt; + +import com.soopgyeol.api.config.auth.CustomUserDetails; +import com.soopgyeol.api.domain.user.Role; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.UserRepository; +import com.soopgyeol.api.service.jwt.JwtProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + // carbon 관련 경로만 임시 인증 우회 +// if (path.startsWith("/carbon")) { +// CustomUserDetails testUserDetails = new CustomUserDetails( +// 0L, +// "test@example.com", +// "", +// List.of(new SimpleGrantedAuthority("ROLE_USER")) +// ); +// +// UsernamePasswordAuthenticationToken authentication = +// new UsernamePasswordAuthenticationToken( +// testUserDetails, +// null, +// testUserDetails.getAuthorities() +// ); +// +// SecurityContextHolder.getContext().setAuthentication(authentication); +// chain.doFilter(request, response); +// return; +// } +// + + + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (header == null || !header.startsWith("Bearer ")) { + chain.doFilter(request, response); + return; + } + + String token = header.substring(7); + + try { + Claims claims = jwtProvider.parse(token); + Long userId = Long.valueOf(claims.getSubject()); + Role role = Role.valueOf((String) claims.get("role")); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("탈퇴한 사용자")); + + CustomUserDetails userDetails = new CustomUserDetails( + user.getId(), + user.getEmail(), + user.getPassword(), + List.of(new SimpleGrantedAuthority("ROLE_" + role.name())) + ); + + UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + userDetails, // Principal + null, + userDetails.getAuthorities() + ); + + SecurityContextHolder.getContext().setAuthentication(auth); + + } catch (ExpiredJwtException e) { + log.warn("JWT 만료: {}", e.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다."); + return; + } catch (JwtException | IllegalArgumentException e) { + log.warn("JWT 오류: {}", e.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다."); + return; + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/api/v1/auth/oauth/") + || path.startsWith("/swagger") + || path.equals("/"); + } + +} + diff --git a/server/src/main/java/com/soopgyeol/api/service/jwt/JwtProvider.java b/server/src/main/java/com/soopgyeol/api/service/jwt/JwtProvider.java new file mode 100644 index 0000000..5a60e06 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/jwt/JwtProvider.java @@ -0,0 +1,74 @@ +package com.soopgyeol.api.service.jwt; + +import com.soopgyeol.api.domain.user.Role; +import io.github.cdimascio.dotenv.Dotenv; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtProvider { + + + private final String secret; + + public JwtProvider(Dotenv dotenv) { + this.secret = dotenv.get("JWT_SECRET"); + } + + private final long ACCESS_VALIDITY = 1000L * 60 * 60 * 24 * 7; // 7일 + + + + private SecretKey getSigningKey() { + byte[] keyBytes = Base64.getDecoder().decode(secret); + return Keys.hmacShaKeyFor(keyBytes); + } + + + public String createToken(Long userId, Role role) { + long now = System.currentTimeMillis(); + + return Jwts.builder() + .setSubject(userId.toString()) + .claim("role", role.name()) + .setIssuedAt(new Date(now)) + .setExpiration(new Date(now + ACCESS_VALIDITY)) + .signWith(getSigningKey()) + .compact(); + } + + + public Claims parse(String token) throws JwtException { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) // 서명·만료 자동 확인 + .getBody(); // -> Claims + } + public Long getUserId(String token) { + Claims claims = Jwts.parserBuilder() + + .setSigningKey(getSigningKey()) // 수정: 일관성 유지하기 위함 + .build() + .parseClaimsJws(token) + .getBody(); + + return Long.valueOf(claims.getSubject()); + } + +} + diff --git a/server/src/main/java/com/soopgyeol/api/service/scheduler/UserGrowthScheduler.java b/server/src/main/java/com/soopgyeol/api/service/scheduler/UserGrowthScheduler.java new file mode 100644 index 0000000..504ff7f --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/scheduler/UserGrowthScheduler.java @@ -0,0 +1,27 @@ +package com.soopgyeol.api.service.scheduler; + +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserGrowthScheduler { + private final UserRepository userRepository; + + // 매일 자정(00:00)에 실행 + @Scheduled(cron = "0 0 0 * * *") + public void increaseAllUsersGrowthPoint() { + List users = userRepository.findAll(); + for (User user : users) { + user.increaseGrowthPoint(10); + } + userRepository.saveAll(users); + log.info("모든 유저의 성장점수를 10점 증가시켰습니다."); + } +} diff --git a/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageService.java b/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageService.java new file mode 100644 index 0000000..4c5d34e --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageService.java @@ -0,0 +1,9 @@ +package com.soopgyeol.api.service.stage; + +import com.soopgyeol.api.domain.stage.dto.TreeStageResponse; + +public interface TreeStageService { + void updateTreeStageByGrowth(Long userId); + + TreeStageResponse getTreeStageMessage(Long userId); +} diff --git a/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageServiceImpl.java b/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageServiceImpl.java new file mode 100644 index 0000000..42f1839 --- /dev/null +++ b/server/src/main/java/com/soopgyeol/api/service/stage/TreeStageServiceImpl.java @@ -0,0 +1,79 @@ +package com.soopgyeol.api.service.stage; + +import com.soopgyeol.api.domain.stage.dto.TreeStageResponse; +import com.soopgyeol.api.domain.stage.entity.Stage; +import com.soopgyeol.api.domain.user.User; +import com.soopgyeol.api.repository.StageRepository; +import com.soopgyeol.api.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TreeStageServiceImpl implements TreeStageService { + private final UserRepository userRepository; + private final StageRepository stageRepository; + + @Override + @Transactional + public void updateTreeStageByGrowth(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Stage userStage = stageRepository.findByUser(user) + .orElseGet(() -> stageRepository.save(Stage.builder() + .user(user) + .treeName("씨앗") + .treeUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/seed.png") + .heroName("Lv.1 새싹지기") + .heroUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/heroseed.png") + .build())); + + int growth = user.getGrowthPoint(); + String treeName; + String treeUrl; + if (growth <= 100) { + treeName = "씨앗"; + treeUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/seed.png"; + } else if (growth <= 300) { + treeName = "새싹"; + treeUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/sappling.png"; + } else if (growth <= 700) { + treeName = "작은 나무"; + treeUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/littletree.png"; + } else { + treeName = "나무"; + treeUrl = "https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/tree.png"; + } + + userStage.setTreeName(treeName); + userStage.setTreeUrl(treeUrl); + stageRepository.save(userStage); + } + + @Override + @Transactional + public TreeStageResponse getTreeStageMessage(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + Stage userStage = stageRepository.findByUser(user) + .orElseGet(() -> stageRepository.save(Stage.builder() + .user(user) + .treeName("씨앗") + .treeUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/seed.png") + .heroName("Lv.1 새싹지기") + .heroUrl("https://soopgyeolbucket.s3.ap-northeast-2.amazonaws.com/hero/heroseed.png") + .build())); + + String treeName = userStage.getTreeName(); + String treeUrl = userStage.getTreeUrl(); + if (treeName == null) { + throw new IllegalArgumentException("사용자의 나무 단계 정보가 없습니다"); + } + + return TreeStageResponse.builder() + .treeName(treeName) + .treeUrl(treeUrl) + .build(); + } +} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index b9cc79d..a599472 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -1,21 +1,48 @@ spring: datasource: - url: jdbc:mysql://localhost:3307/soopgyeol_db?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true - username: root - password: newpassword123! + url: ${DATABASE_URL} + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - jpa: hibernate: - ddl-auto: create + ddl-auto: update #create show-sql: true properties: hibernate: format_sql: true - mvc: pathmatch: matching-strategy: ant_path_matcher +openai: + api: + key: ${OPENAI_API_KEY} + server: - port: 3005 \ No newline at end of file + port: 8080 + +oauth: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: https://soopgyeol.site/api/v1/auth/oauth/oauth2/google/code-log #${GOOGLE_REDIRECT_URI} + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: https://soopgyeol.site/api/v1/auth/oauth/oauth2/kakao/code-log #${KAKAO_REDIRECT_URI} + +jwt: + secret: ${JWT_SECRET} + +cloud: + aws: + credentials: + access-key: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY} + secret-key: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY} + s3: + bucket: ${BUCKET_NAME} + region: + static: ${CLOUD_AWS_REGION_STATIC} + stack: + auto: false