mirror of
https://github.com/cna-bootcamp/lifesub.git
synced 2026-06-13 04:59:10 +00:00
release
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package com.unicorn.lifesub.recommend;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
|
||||
public class RecommendApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(RecommendApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.unicorn.lifesub.recommend.config;
|
||||
|
||||
import com.unicorn.lifesub.recommend.config.jwt.JwtAuthenticationFilter;
|
||||
import com.unicorn.lifesub.recommend.config.jwt.JwtTokenProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@SuppressWarnings("unused")
|
||||
public class SecurityConfig {
|
||||
protected final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${allowedorigins}")
|
||||
private String allowedOrigins;
|
||||
|
||||
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(cors -> cors
|
||||
.configurationSource(corsConfigurationSource())
|
||||
)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs.yaml", "/v3/api-docs/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
protected CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.unicorn.lifesub.recommend.config;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@SuppressWarnings("unused")
|
||||
@SecurityScheme(
|
||||
name = "bearerAuth",
|
||||
type = SecuritySchemeType.HTTP,
|
||||
bearerFormat = "JWT",
|
||||
scheme = "bearer"
|
||||
)
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("구독추천 서비스 API")
|
||||
.version("v1.0.0")
|
||||
.description("구독추천 서비스 API 명세서입니다."));
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// CommonJwtAuthenticationFilter.java
|
||||
package com.unicorn.lifesub.recommend.config.jwt;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.NonNull;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||
this.jwtTokenProvider = jwtTokenProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String token = resolveToken(request);
|
||||
|
||||
if (token != null && jwtTokenProvider.validateToken(token)) {
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtTokenProvider.getAuthentication(token));
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// CommonJwtTokenProvider.java
|
||||
package com.unicorn.lifesub.recommend.config.jwt;
|
||||
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
import com.unicorn.lifesub.common.exception.ErrorCode;
|
||||
import com.unicorn.lifesub.common.exception.InfraException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
private final Algorithm algorithm;
|
||||
|
||||
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
|
||||
this.algorithm = Algorithm.HMAC512(secretKey);
|
||||
}
|
||||
|
||||
public Authentication getAuthentication(String token) {
|
||||
try {
|
||||
DecodedJWT decodedJWT = JWT.decode(token);
|
||||
String username = decodedJWT.getSubject();
|
||||
String[] authStrings = decodedJWT.getClaim("auth").asArray(String.class);
|
||||
Collection<? extends GrantedAuthority> authorities = Arrays.stream(authStrings)
|
||||
.map(SimpleGrantedAuthority::new)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
UserDetails userDetails = new User(username, "", authorities);
|
||||
|
||||
return new UsernamePasswordAuthenticationToken(userDetails, "", authorities);
|
||||
} catch (Exception e) {
|
||||
throw new InfraException(ErrorCode.INVALID_CREDENTIALS);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
JWTVerifier verifier = JWT.require(algorithm).build();
|
||||
verifier.verify(token);
|
||||
return true;
|
||||
} catch (JWTVerificationException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.unicorn.lifesub.recommend.controller;
|
||||
|
||||
import com.unicorn.lifesub.common.dto.ApiResponse;
|
||||
import com.unicorn.lifesub.recommend.dto.RecommendCategoryDTO;
|
||||
import com.unicorn.lifesub.recommend.service.RecommendService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "구독추천 API", description = "구독추천 관련 API")
|
||||
@RestController
|
||||
@RequestMapping("/api/recommend")
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendController {
|
||||
private final RecommendService recommendService;
|
||||
|
||||
@Operation(summary = "추천 카테고리 조회",
|
||||
description = "사용자의 지출 패턴을 분석하여 추천 구독 카테고리를 제공합니다.")
|
||||
@GetMapping("/categories")
|
||||
public ResponseEntity<ApiResponse<RecommendCategoryDTO>> getRecommendedCategory(
|
||||
@RequestParam String userId) {
|
||||
RecommendCategoryDTO response = recommendService.getRecommendedCategory(userId);
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.unicorn.lifesub.recommend.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Getter
|
||||
public class RecommendedCategory {
|
||||
private final String spendingCategory;
|
||||
private final String recommendCategory;
|
||||
private final LocalDate baseDate;
|
||||
|
||||
@Builder
|
||||
public RecommendedCategory(String spendingCategory, String recommendCategory, LocalDate baseDate) {
|
||||
this.spendingCategory = spendingCategory;
|
||||
this.recommendCategory = recommendCategory;
|
||||
this.baseDate = baseDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.unicorn.lifesub.recommend.domain;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class SpendingCategory {
|
||||
private final String category;
|
||||
private final Long totalAmount;
|
||||
|
||||
@Builder
|
||||
public SpendingCategory(String category, Long totalAmount) {
|
||||
this.category = category;
|
||||
this.totalAmount = totalAmount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.unicorn.lifesub.recommend.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public class RecommendCategoryDTO {
|
||||
private String categoryName;
|
||||
private String imagePath;
|
||||
private LocalDate baseDate;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.unicorn.lifesub.recommend.repository.entity;
|
||||
|
||||
import com.unicorn.lifesub.recommend.domain.RecommendedCategory;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Table(name = "recommended_categories")
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class RecommendedCategoryEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String spendingCategory;
|
||||
private String recommendCategory;
|
||||
|
||||
@Builder
|
||||
public RecommendedCategoryEntity(Long id, String spendingCategory, String recommendCategory) {
|
||||
this.id = id;
|
||||
this.spendingCategory = spendingCategory;
|
||||
this.recommendCategory = recommendCategory;
|
||||
}
|
||||
|
||||
public RecommendedCategory toDomain() {
|
||||
return RecommendedCategory.builder()
|
||||
.spendingCategory(spendingCategory)
|
||||
.recommendCategory(recommendCategory)
|
||||
.baseDate(java.time.LocalDate.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.unicorn.lifesub.recommend.repository.entity;
|
||||
|
||||
import com.unicorn.lifesub.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "spending_history")
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
public class SpendingEntity extends BaseTimeEntity {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String userId;
|
||||
private String category;
|
||||
private Long amount;
|
||||
private LocalDate spendingDate;
|
||||
|
||||
@Builder
|
||||
public SpendingEntity(Long id, String userId, String category, Long amount, LocalDate spendingDate) {
|
||||
this.id = id;
|
||||
this.userId = userId;
|
||||
this.category = category;
|
||||
this.amount = amount;
|
||||
this.spendingDate = spendingDate;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.unicorn.lifesub.recommend.repository.jpa;
|
||||
|
||||
import com.unicorn.lifesub.recommend.repository.entity.RecommendedCategoryEntity;
|
||||
import com.unicorn.lifesub.recommend.repository.entity.SpendingEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RecommendRepository extends JpaRepository<RecommendedCategoryEntity, Long> {
|
||||
Optional<RecommendedCategoryEntity> findBySpendingCategory(String category);
|
||||
|
||||
@Query("SELECT s FROM SpendingEntity s WHERE s.userId = :userId AND s.spendingDate >= :startDate")
|
||||
List<SpendingEntity> findSpendingsByUserIdAndDateAfter(@Param("userId") String userId,
|
||||
@Param("startDate") LocalDate startDate);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.unicorn.lifesub.recommend.service;
|
||||
|
||||
import com.unicorn.lifesub.recommend.dto.RecommendCategoryDTO;
|
||||
|
||||
public interface RecommendService {
|
||||
RecommendCategoryDTO getRecommendedCategory(String userId);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.unicorn.lifesub.recommend.service;
|
||||
|
||||
import com.unicorn.lifesub.common.exception.BusinessException;
|
||||
import com.unicorn.lifesub.common.exception.ErrorCode;
|
||||
import com.unicorn.lifesub.recommend.domain.RecommendedCategory;
|
||||
import com.unicorn.lifesub.recommend.domain.SpendingCategory;
|
||||
import com.unicorn.lifesub.recommend.dto.RecommendCategoryDTO;
|
||||
import com.unicorn.lifesub.recommend.repository.jpa.RecommendRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RecommendServiceImpl implements RecommendService {
|
||||
private final RecommendRepository recommendRepository;
|
||||
private final SpendingAnalyzer spendingAnalyzer;
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public RecommendCategoryDTO getRecommendedCategory(String userId) {
|
||||
LocalDate startDate = LocalDate.now().minusMonths(1);
|
||||
|
||||
SpendingCategory topSpending = spendingAnalyzer.analyzeSpending(
|
||||
recommendRepository.findSpendingsByUserIdAndDateAfter(userId, startDate)
|
||||
);
|
||||
|
||||
if (topSpending == null) {
|
||||
throw new BusinessException(ErrorCode.NO_SPENDING_DATA);
|
||||
}
|
||||
|
||||
RecommendedCategory recommendedCategory = recommendRepository
|
||||
.findBySpendingCategory(topSpending.getCategory())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NO_SPENDING_DATA))
|
||||
.toDomain();
|
||||
|
||||
return RecommendCategoryDTO.builder()
|
||||
.categoryName(recommendedCategory.getRecommendCategory())
|
||||
.imagePath(getCategoryImagePath(recommendedCategory.getRecommendCategory()))
|
||||
.baseDate(recommendedCategory.getBaseDate())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String getCategoryImagePath(String category) {
|
||||
return "/images/categories/" + category.toLowerCase() + ".png";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.unicorn.lifesub.recommend.service;
|
||||
|
||||
import com.unicorn.lifesub.recommend.domain.SpendingCategory;
|
||||
import com.unicorn.lifesub.recommend.repository.entity.SpendingEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class SpendingAnalyzer {
|
||||
public SpendingCategory analyzeSpending(List<SpendingEntity> spendings) {
|
||||
Map<String, Long> totalsByCategory = calculateTotalByCategory(spendings);
|
||||
return findTopCategory(totalsByCategory);
|
||||
}
|
||||
|
||||
private Map<String, Long> calculateTotalByCategory(List<SpendingEntity> spendings) {
|
||||
return spendings.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
SpendingEntity::getCategory,
|
||||
Collectors.summingLong(SpendingEntity::getAmount)
|
||||
));
|
||||
}
|
||||
|
||||
private SpendingCategory findTopCategory(Map<String, Long> totals) {
|
||||
return totals.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue())
|
||||
.map(entry -> SpendingCategory.builder()
|
||||
.category(entry.getKey())
|
||||
.totalAmount(entry.getValue())
|
||||
.build())
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: recommend-service
|
||||
datasource:
|
||||
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommend}
|
||||
username: ${POSTGRES_USER:postgres}
|
||||
password: ${POSTGRES_PASSWORD:postgres}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:validate}
|
||||
show-sql: ${JPA_SHOW_SQL:false}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
|
||||
allowedorigins: ${ALLOWED_ORIGINS:*}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
api-docs:
|
||||
path: /api-docs
|
||||
Reference in New Issue
Block a user