This commit is contained in:
ondal
2025-02-12 21:24:01 +09:00
commit 7a4f60c842
222 changed files with 3018 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
}
bootJar {
archiveFileName = "recommend.jar"
}
@@ -0,0 +1,26 @@
server:
port: ${SERVER_PORT:8083}
spring:
application:
name: recommend-service
datasource:
url: ${POSTGRES_URL}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
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
@@ -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 명세서입니다."));
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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;
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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