This commit is contained in:
ondal
2025-02-13 00:16:35 +09:00
parent 2b100e1bcf
commit 11a49c33a8
101 changed files with 518 additions and 118 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -51,7 +51,7 @@ configure(subprojects.findAll { !it.name.endsWith('-biz') && it.name != 'common'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
}
}
Binary file not shown.
@@ -16,7 +16,9 @@ import java.util.Map;
public class LoggingAspect {
private final Gson gson = new Gson();
@Pointcut("execution(* com.unicorn..*.*(..))")
//로깅 대상 패키지 지정. swagger관련 패키지는 제외함
@Pointcut("execution(* com.unicorn..*.*(..)) && " +
"!execution(* org.springdoc..*.*(..))")
private void loggingPointcut() {}
@Before("loggingPointcut()")
@@ -0,0 +1,9 @@
package com.unicorn.lifesub.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}
@@ -7,23 +7,24 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum ErrorCode {
// Common
INVALID_INPUT_VALUE(400, "Invalid input value"),
INTERNAL_SERVER_ERROR(500, "Internal server error"),
INVALID_INPUT_VALUE(100, "Invalid input value"),
INTERNAL_SERVER_ERROR(110, "Internal server error"),
// Member
MEMBER_NOT_FOUND(404, "Member not found"),
INVALID_CREDENTIALS(401, "Invalid credentials"),
TOKEN_EXPIRED(401, "Token expired"),
SIGNATURE_VERIFICATION_EXCEPTION(20, "서명 검증 실패"),
ALGORITHM_MISMATCH_EXCEPTION(30, "알고리즘 불일치"),
INVALID_CLAIM_EXCEPTION(40, "유효하지 않은 클레임"),
MEMBER_NOT_FOUND(200, "Member not found"),
INVALID_CREDENTIALS(210, "Invalid credentials"),
TOKEN_EXPIRED(220, "Token expired"),
SIGNATURE_VERIFICATION_EXCEPTION(230, "서명 검증 실패"),
ALGORITHM_MISMATCH_EXCEPTION(240, "알고리즘 불일치"),
INVALID_CLAIM_EXCEPTION(250, "유효하지 않은 클레임"),
// Subscription
SUBSCRIPTION_NOT_FOUND(404, "Subscription not found"),
ALREADY_SUBSCRIBED(400, "Already subscribed to this service"),
SUBSCRIPTION_NOT_FOUND(300, "Subscription not found"),
ALREADY_SUBSCRIBED(310, "Already subscribed to this service"),
// Recommend
NO_SPENDING_DATA(404, "No spending data found"),
NO_SPENDING_DATA(400, "No spending data found"),
NO_RECOMMENDATION_DATA(410, "추천 구독 카테고리 없음"),
// UnDefined
UNDIFINED_ERROR(0, "정의되지 않은 에러");
+1 -3
View File
@@ -25,13 +25,11 @@ jwt:
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
allowedorigins: ${ALLOWED_ORIGINS:*}
allowed-origins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
logging:
level:
@@ -2,10 +2,16 @@ package com.unicorn.lifesub.member;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@SpringBootApplication(
scanBasePackages = {
"com.unicorn.lifesub.member",
"com.unicorn.lifesub.common"
}
)
public class MemberApplication {
public static void main(String[] args) {
SpringApplication.run(MemberApplication.class, args);
}
}
}
@@ -1,4 +1,4 @@
// File: lifesub/member/src/main/java/com/unicorn/lifesub/member/config/InitialDataLoader.java
// File: lifesub/member/src/main/java/com/unicorn/lifesub/member/config/DataLoader.java
package com.unicorn.lifesub.member.config;
import com.unicorn.lifesub.member.repository.entity.MemberEntity;
@@ -15,7 +15,7 @@ import java.util.stream.IntStream;
@Component
@RequiredArgsConstructor
public class InitialDataLoader implements CommandLineRunner {
public class DataLoader implements CommandLineRunner {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@@ -26,7 +26,7 @@ public class InitialDataLoader implements CommandLineRunner {
if (memberRepository.count() == 0) {
Set<String> userRoles = new HashSet<>();
userRoles.add("USER");
String encodedPassword = passwordEncoder.encode("P@ssw0rd$");
String encodedPassword = passwordEncoder.encode("Passw0rd");
IntStream.rangeClosed(1, 10).forEach(i -> {
String userId = String.format("user%02d", i);
@@ -31,7 +31,7 @@ public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService customUserDetailsService;
@Value("${allowedorigins}")
@Value("${allowed-origins}")
private String allowedOrigins;
public SecurityConfig(JwtTokenProvider jwtTokenProvider, CustomUserDetailsService customUserDetailsService) {
@@ -1,6 +1,7 @@
package com.unicorn.lifesub.member.config;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
+1 -3
View File
@@ -25,13 +25,11 @@ jwt:
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
allowedorigins: ${ALLOWED_ORIGINS:*}
allowed-origins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
logging:
level:
Binary file not shown.
@@ -0,0 +1,15 @@
package com.unicorn.lifesub.mysub.biz.service;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum FeeLevel {
LIKFER("liker"),
COLLECTOR("collector"),
ADDICT("addict");
private final String feeLevel;
}
@@ -1,11 +1,13 @@
package com.unicorn.lifesub.mysub.biz.service;
import com.unicorn.lifesub.mysub.biz.domain.Category;
import com.unicorn.lifesub.mysub.biz.domain.MySubscription;
import com.unicorn.lifesub.mysub.biz.dto.MySubResponse;
import com.unicorn.lifesub.mysub.biz.dto.TotalFeeResponse;
import com.unicorn.lifesub.mysub.biz.usecase.in.MySubscriptionsUseCase;
import com.unicorn.lifesub.mysub.biz.usecase.in.TotalFeeUseCase;
import com.unicorn.lifesub.mysub.biz.usecase.out.MySubscriptionReader;
import com.unicorn.lifesub.mysub.biz.domain.Subscription;
import com.unicorn.lifesub.mysub.biz.dto.*;
import com.unicorn.lifesub.mysub.biz.usecase.in.*;
import com.unicorn.lifesub.mysub.biz.usecase.out.*;
import com.unicorn.lifesub.common.exception.BusinessException;
import com.unicorn.lifesub.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,8 +17,17 @@ import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class MySubscriptionService implements TotalFeeUseCase, MySubscriptionsUseCase {
public class MySubscriptionService implements
TotalFeeUseCase,
MySubscriptionsUseCase,
SubscriptionDetailUseCase,
SubscribeUseCase,
CancelSubscriptionUseCase,
CategoryUseCase {
private final MySubscriptionReader mySubscriptionReader;
private final MySubscriptionWriter mySubscriptionWriter;
private final SubscriptionReader subscriptionReader;
@Override
@Transactional(readOnly = true)
@@ -43,9 +54,66 @@ public class MySubscriptionService implements TotalFeeUseCase, MySubscriptionsUs
.collect(Collectors.toList());
}
private String calculateFeeLevel(long totalFee) {
if (totalFee < 100000) return "구독을 좋아하는 사람";
if (totalFee < 200000) return "구독 수집자";
return "구독 사치왕";
@Override
@Transactional(readOnly = true)
public SubDetailResponse getSubscriptionDetail(Long subscriptionId) {
Subscription subscription = subscriptionReader.findById(subscriptionId)
.orElseThrow(() -> new BusinessException(ErrorCode.SUBSCRIPTION_NOT_FOUND));
return SubDetailResponse.builder()
.serviceName(subscription.getName())
.logoUrl(subscription.getLogoUrl())
.category(subscription.getCategory())
.description(subscription.getDescription())
.price(subscription.getPrice())
.maxSharedUsers(subscription.getMaxSharedUsers())
.build();
}
}
@Override
@Transactional
public void subscribe(Long subscriptionId, String userId) {
// 구독 서비스 존재 확인
subscriptionReader.findById(subscriptionId)
.orElseThrow(() -> new BusinessException(ErrorCode.SUBSCRIPTION_NOT_FOUND));
mySubscriptionWriter.save(userId, subscriptionId);
}
@Override
@Transactional
public void cancel(Long subscriptionId) {
mySubscriptionWriter.delete(subscriptionId);
}
@Override
@Transactional(readOnly = true)
public List<CategoryResponse> getAllCategories() {
return subscriptionReader.findAllCategories().stream()
.map(category -> CategoryResponse.builder()
.categoryId(category.getCategoryId())
.categoryName(category.getName())
.build())
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<ServiceListResponse> getServicesByCategory(String categoryId) {
return subscriptionReader.findByCategory(categoryId).stream()
.map(subscription -> ServiceListResponse.builder()
.serviceId(subscription.getId().toString())
.serviceName(subscription.getName())
.description(subscription.getDescription())
.price(subscription.getPrice())
.logoUrl(subscription.getLogoUrl())
.build())
.collect(Collectors.toList());
}
private String calculateFeeLevel(long totalFee) {
if (totalFee < 100000) return FeeLevel.LIKFER.getFeeLevel();
if (totalFee < 200000) return FeeLevel.COLLECTOR.getFeeLevel();
return FeeLevel.ADDICT.getFeeLevel();
}
}
+1
View File
@@ -0,0 +1 @@
com.unicorn.lifesub.mysub.infra.MySubApplication
@@ -5,22 +5,30 @@ spring:
application:
name: mysub-service
datasource:
url: ${POSTGRES_URL}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:mysub}
username: ${POSTGRES_USER:admin}
password: ${POSTGRES_PASSWORD:Passw0rd}
driver-class-name: org.postgresql.Driver
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
allowedorigins: ${ALLOWED_ORIGINS:*}
jwt:
secret-key: ${JWT_SECRET_KEY:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ}
allowed-origins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
logging:
level:
com.unicorn: DEBUG
org.hibernate.SQL: TRACE
@@ -2,8 +2,14 @@ package com.unicorn.lifesub.mysub.infra;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@SpringBootApplication
@SpringBootApplication(
scanBasePackages = {
"com.unicorn.lifesub.mysub",
"com.unicorn.lifesub.common"
}
)
public class MySubApplication {
public static void main(String[] args) {
SpringApplication.run(MySubApplication.class, args);
@@ -0,0 +1,128 @@
// File: lifesub/mysub-infra/src/main/java/com/unicorn/lifesub/mysub/infra/config/DataLoader.java
package com.unicorn.lifesub.mysub.infra.config;
import com.unicorn.lifesub.mysub.infra.gateway.entity.CategoryEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.repository.CategoryJpaRepository;
import com.unicorn.lifesub.mysub.infra.gateway.repository.SubscriptionJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataLoader implements CommandLineRunner {
private final CategoryJpaRepository categoryRepository;
private final SubscriptionJpaRepository subscriptionRepository;
@Override
@Transactional
public void run(String... args) {
if (categoryRepository.count() == 0) {
loadCategories();
}
if (subscriptionRepository.count() == 0) {
loadSubscriptions();
}
}
private void loadCategories() {
log.info("Loading sample categories...");
List<CategoryEntity> categories = Arrays.asList(
CategoryEntity.builder()
.categoryId("OTT")
.name("OTT/동영상")
.build(),
CategoryEntity.builder()
.categoryId("MUSIC")
.name("음악")
.build(),
CategoryEntity.builder()
.categoryId("FOOD")
.name("식품")
.build(),
CategoryEntity.builder()
.categoryId("LIFE")
.name("생활")
.build(),
CategoryEntity.builder()
.categoryId("BEAUTY")
.name("뷰티")
.build(),
CategoryEntity.builder()
.categoryId("EDU")
.name("교육")
.build()
);
categoryRepository.saveAll(categories);
log.info("Sample categories loaded");
}
private void loadSubscriptions() {
log.info("Loading sample subscriptions...");
// 카테고리별 서비스 매핑
Map<String, List<SubscriptionEntity>> subscriptionsByCategory = Map.of(
"OTT", Arrays.asList(
createSubscription("넷플릭스", "전세계 최대 OTT 서비스", "OTT", 17000, 4, "/images/netflix.png"),
createSubscription("티빙", "국내 실시간 방송과 예능/드라마 VOD", "OTT", 13900, 4, "/images/tving.png"),
createSubscription("디즈니플러스", "디즈니, 픽사, 마블, 스타워즈 콘텐츠", "OTT", 9900, 4, "/images/disney.png")
),
"MUSIC", Arrays.asList(
createSubscription("멜론", "국내 최대 음원 스트리밍", "MUSIC", 10900, 1, "/images/melon.png"),
createSubscription("스포티파이", "전세계 음악 스트리밍", "MUSIC", 10900, 6, "/images/spotify.png"),
createSubscription("유튜브 뮤직", "유튜브 음원 스트리밍", "MUSIC", 8900, 5, "/images/youtube-music.png")
),
"FOOD", Arrays.asList(
createSubscription("쿠팡이츠", "식품 정기배송", "FOOD", 4900, 1, "/images/coupang-eats.png"),
createSubscription("마켓컬리", "신선식품 새벽배송", "FOOD", 4900, 1, "/images/kurly.png"),
createSubscription("배민", "배달음식 구독", "FOOD", 5900, 1, "/images/baemin.png")
),
"LIFE", Arrays.asList(
createSubscription("당근", "중고거래 프리미엄", "LIFE", 3900, 1, "/images/karrot.png"),
createSubscription("쿠팡 로켓와우", "무료배송 구독", "LIFE", 4900, 4, "/images/coupang.png"),
createSubscription("밀리의 서재", "전자책 구독", "LIFE", 9900, 1, "/images/millie.png")
),
"BEAUTY", Arrays.asList(
createSubscription("올리브영", "뷰티 정기구독", "BEAUTY", 15900, 1, "/images/oliveyoung.png"),
createSubscription("시코르", "화장품 구독박스", "BEAUTY", 29900, 1, "/images/chicor.png"),
createSubscription("롭스", "뷰티 멤버십", "BEAUTY", 19900, 1, "/images/lohbs.png")
),
"EDU", Arrays.asList(
createSubscription("클래스101", "취미/교양 클래스", "EDU", 19900, 1, "/images/class101.png"),
createSubscription("탈잉", "원데이 클래스", "EDU", 29900, 1, "/images/taling.png"),
createSubscription("캐치", "IT 실무 교육", "EDU", 99000, 1, "/images/catch.png")
)
);
// 모든 서비스 저장
subscriptionsByCategory.values().stream()
.flatMap(List::stream)
.forEach(subscriptionRepository::save);
log.info("Sample subscriptions loaded");
}
private SubscriptionEntity createSubscription(String name, String description, String category,
int price, int maxSharedUsers, String logoUrl) {
return SubscriptionEntity.builder()
.name(name)
.description(description)
.category(category)
.price(price)
.maxSharedUsers(maxSharedUsers)
.logoUrl(logoUrl)
.build();
}
}
@@ -24,7 +24,7 @@ import java.util.List;
public class SecurityConfig {
protected final JwtTokenProvider jwtTokenProvider;
@Value("${allowedorigins}")
@Value("${allowed-origins}")
private String allowedOrigins;
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
@@ -25,7 +25,7 @@ import java.util.stream.Collectors;
public class JwtTokenProvider {
private final Algorithm algorithm;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
this.algorithm = Algorithm.HMAC512(secretKey);
}
@@ -5,6 +5,7 @@ import com.unicorn.lifesub.mysub.biz.dto.CategoryResponse;
import com.unicorn.lifesub.mysub.biz.dto.ServiceListResponse;
import com.unicorn.lifesub.mysub.biz.usecase.in.CategoryUseCase;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -17,6 +18,7 @@ import java.util.List;
@Tag(name = "구독 카테고리 API", description = "구독 카테고리 관련 API")
@RestController
@SecurityRequirement(name = "bearerAuth") //이 어노테이션이 없으면 요청 헤더에 Authorization헤더가 안 생김
@RequestMapping("/api/mysub")
@RequiredArgsConstructor
public class CategoryController {
@@ -6,6 +6,7 @@ import com.unicorn.lifesub.mysub.biz.dto.TotalFeeResponse;
import com.unicorn.lifesub.mysub.biz.usecase.in.MySubscriptionsUseCase;
import com.unicorn.lifesub.mysub.biz.usecase.in.TotalFeeUseCase;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -15,6 +16,7 @@ import java.util.List;
@Tag(name = "마이구독 API", description = "마이구독 관련 API")
@RestController
@SecurityRequirement(name = "bearerAuth") //이 어노테이션이 없으면 요청 헤더에 Authorization헤더가 안 생김
@RequestMapping("/api/mysub")
@RequiredArgsConstructor
public class MySubController {
@@ -6,6 +6,7 @@ import com.unicorn.lifesub.mysub.biz.usecase.in.CancelSubscriptionUseCase;
import com.unicorn.lifesub.mysub.biz.usecase.in.SubscribeUseCase;
import com.unicorn.lifesub.mysub.biz.usecase.in.SubscriptionDetailUseCase;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
@Tag(name = "구독 서비스 API", description = "구독 서비스 관련 API")
@RestController
@SecurityRequirement(name = "bearerAuth") //이 어노테이션이 없으면 요청 헤더에 Authorization헤더가 안 생김
@RequestMapping("/api/mysub/services")
@RequiredArgsConstructor
public class ServiceController {
@@ -1,14 +1,14 @@
package com.unicorn.lifesub.mysub.infra.adapter;
package com.unicorn.lifesub.mysub.infra.gateway;
import com.unicorn.lifesub.common.exception.BusinessException;
import com.unicorn.lifesub.common.exception.ErrorCode;
import com.unicorn.lifesub.mysub.biz.domain.MySubscription;
import com.unicorn.lifesub.mysub.biz.usecase.out.MySubscriptionReader;
import com.unicorn.lifesub.mysub.biz.usecase.out.MySubscriptionWriter;
import com.unicorn.lifesub.mysub.infra.entity.MySubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.repository.MySubscriptionJpaRepository;
import com.unicorn.lifesub.mysub.infra.repository.SubscriptionJpaRepository;
import com.unicorn.lifesub.mysub.infra.gateway.entity.MySubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.repository.MySubscriptionJpaRepository;
import com.unicorn.lifesub.mysub.infra.gateway.repository.SubscriptionJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -18,7 +18,7 @@ import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class MySubscriptionAdapter implements MySubscriptionReader, MySubscriptionWriter {
public class MySubscriptionGateway implements MySubscriptionReader, MySubscriptionWriter {
private final MySubscriptionJpaRepository mySubscriptionRepository;
private final SubscriptionJpaRepository subscriptionRepository;
@@ -1,14 +1,12 @@
package com.unicorn.lifesub.mysub.infra.adapter;
package com.unicorn.lifesub.mysub.infra.gateway;
import com.unicorn.lifesub.common.exception.BusinessException;
import com.unicorn.lifesub.common.exception.ErrorCode;
import com.unicorn.lifesub.mysub.biz.domain.Subscription;
import com.unicorn.lifesub.mysub.biz.domain.Category;
import com.unicorn.lifesub.mysub.biz.usecase.out.SubscriptionReader;
import com.unicorn.lifesub.mysub.infra.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.entity.CategoryEntity;
import com.unicorn.lifesub.mysub.infra.repository.SubscriptionJpaRepository;
import com.unicorn.lifesub.mysub.infra.repository.CategoryJpaRepository;
import com.unicorn.lifesub.mysub.infra.gateway.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.CategoryEntity;
import com.unicorn.lifesub.mysub.infra.gateway.repository.SubscriptionJpaRepository;
import com.unicorn.lifesub.mysub.infra.gateway.repository.CategoryJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -18,7 +16,7 @@ import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class SubscriptionAdapter implements SubscriptionReader {
public class SubscriptionGateway implements SubscriptionReader {
private final SubscriptionJpaRepository subscriptionRepository;
private final CategoryJpaRepository categoryRepository;
@@ -1,13 +1,13 @@
package com.unicorn.lifesub.mysub.infra.entity;
package com.unicorn.lifesub.mysub.infra.gateway.entity;
import com.unicorn.lifesub.common.entity.BaseTimeEntity;
import com.unicorn.lifesub.mysub.biz.domain.Category;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
@Entity
@Table(name = "categories")
@@ -1,4 +1,4 @@
package com.unicorn.lifesub.mysub.infra.entity;
package com.unicorn.lifesub.mysub.infra.gateway.entity;
import com.unicorn.lifesub.mysub.biz.domain.MySubscription;
import jakarta.persistence.*;
@@ -1,4 +1,4 @@
package com.unicorn.lifesub.mysub.infra.entity;
package com.unicorn.lifesub.mysub.infra.gateway.entity;
import com.unicorn.lifesub.common.entity.BaseTimeEntity;
import com.unicorn.lifesub.mysub.biz.domain.Subscription;
@@ -1,6 +1,6 @@
package com.unicorn.lifesub.mysub.infra.repository;
package com.unicorn.lifesub.mysub.infra.gateway.repository;
import com.unicorn.lifesub.mysub.infra.entity.CategoryEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.CategoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryJpaRepository extends JpaRepository<CategoryEntity, String> {
@@ -1,6 +1,6 @@
package com.unicorn.lifesub.mysub.infra.repository;
package com.unicorn.lifesub.mysub.infra.gateway.repository;
import com.unicorn.lifesub.mysub.infra.entity.MySubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.MySubscriptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -1,6 +1,6 @@
package com.unicorn.lifesub.mysub.infra.repository;
package com.unicorn.lifesub.mysub.infra.gateway.repository;
import com.unicorn.lifesub.mysub.infra.entity.SubscriptionEntity;
import com.unicorn.lifesub.mysub.infra.gateway.entity.SubscriptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
+11 -6
View File
@@ -9,21 +9,26 @@ spring:
username: ${POSTGRES_USER:admin}
password: ${POSTGRES_PASSWORD:Passw0rd}
driver-class-name: org.postgresql.Driver
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:false}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
format_sql: true
allowedorigins: ${ALLOWED_ORIGINS:*}
dialect: org.hibernate.dialect.PostgreSQLDialect
jwt:
secret: ${JWT_SECRET:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ==}
secret-key: ${JWT_SECRET_KEY:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ}
allowed-origins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
logging:
level:
com.unicorn: DEBUG
org.hibernate.SQL: TRACE
+1
View File
@@ -0,0 +1 @@
com.unicorn.lifesub.recommend.RecommendApplication
+17 -8
View File
@@ -5,22 +5,31 @@ spring:
application:
name: recommend-service
datasource:
url: ${POSTGRES_URL}
username: ${POSTGRES_USER}
password: ${POSTGRES_PASSWORD}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommend}
username: ${POSTGRES_USER:admin}
password: ${POSTGRES_PASSWORD:Passw0rd}
driver-class-name: org.postgresql.Driver
# JPA 설정
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:validate}
show-sql: ${JPA_SHOW_SQL:false}
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect
allowedorigins: ${ALLOWED_ORIGINS:*}
jwt:
secret-key: ${JWT_SECRET_KEY:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ}
allowed-origins: ${ALLOWED_ORIGINS:*}
springdoc:
swagger-ui:
path: /swagger-ui.html
api-docs:
path: /api-docs
logging:
level:
com.unicorn: DEBUG
org.hibernate.SQL: TRACE
@@ -3,8 +3,12 @@ package com.unicorn.lifesub.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@SpringBootApplication(
scanBasePackages = {
"com.unicorn.lifesub.recommend",
"com.unicorn.lifesub.common"
}
)
public class RecommendApplication {
public static void main(String[] args) {
SpringApplication.run(RecommendApplication.class, args);
@@ -0,0 +1,113 @@
// File: lifesub/recommend/src/main/java/com/unicorn/lifesub/recommend/config/DataLoader.java
package com.unicorn.lifesub.recommend.config;
import com.unicorn.lifesub.recommend.repository.entity.RecommendedCategoryEntity;
import com.unicorn.lifesub.recommend.repository.entity.SpendingEntity;
import com.unicorn.lifesub.recommend.repository.jpa.RecommendRepository;
import com.unicorn.lifesub.recommend.repository.jpa.SpendingRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataLoader implements CommandLineRunner {
private final RecommendRepository recommendRepository;
private final SpendingRepository spendingRepository;
private final Random random = new Random();
private static final List<String> SPENDING_CATEGORIES = Arrays.asList(
"COSMETICS", "ENTERTAINMENT", "EDUCATION", "RESTAURANT", "MUSIC", "DAILY"
);
private static final List<String> SUBSCRIPTION_CATEGORIES = Arrays.asList(
"BEAUTY", "OTT", "EDU", "FOOD", "MUSIC", "LIFE"
);
@Override // CommandLineRunner의 run 메소드 구현
@Transactional
public void run(String... args) throws Exception {
log.info("Initializing sample data...");
initSpendingHistory();
initRecommendedCategories();
log.info("Sample data initialization completed.");
}
private void initSpendingHistory() {
// 기존 데이터 삭제
spendingRepository.deleteAll();
List<SpendingEntity> spendings = new ArrayList<>();
LocalDate now = LocalDate.now();
// user01 ~ user10까지의 지출 데이터 생성
for (int i = 1; i <= 10; i++) {
String userId = String.format("user%02d", i);
// 각 사용자별로 지난 한달간 5~10건의 지출 데이터 생성
int numTransactions = 5 + random.nextInt(6);
for (int j = 0; j < numTransactions; j++) {
String category = SPENDING_CATEGORIES.get(random.nextInt(SPENDING_CATEGORIES.size()));
long amount = (50 + random.nextInt(451)) * 1000L; // 5만원 ~ 50만원 사이
int daysAgo = random.nextInt(30); // 최근 30일 이내
spendings.add(SpendingEntity.builder()
.userId(userId)
.category(category)
.amount(amount)
.spendingDate(now.minusDays(daysAgo))
.build());
}
}
spendingRepository.saveAll(spendings);
log.info("Spending history data initialized with {} records", spendings.size());
}
private void initRecommendedCategories() {
// 기존 데이터 삭제
recommendRepository.deleteAll();
// 지출 카테고리와 추천 구독 카테고리 매핑 데이터 생성
List<RecommendedCategoryEntity> categories = Arrays.asList(
RecommendedCategoryEntity.builder()
.spendingCategory("COSMETICS")
.recommendCategory("BEAUTY")
.build(),
RecommendedCategoryEntity.builder()
.spendingCategory("ENTERTAINMENT")
.recommendCategory("OTT")
.build(),
RecommendedCategoryEntity.builder()
.spendingCategory("EDUCATION")
.recommendCategory("EDU")
.build(),
RecommendedCategoryEntity.builder()
.spendingCategory("RESTAURANT")
.recommendCategory("FOOD")
.build(),
RecommendedCategoryEntity.builder()
.spendingCategory("MUSIC")
.recommendCategory("MUSIC")
.build(),
RecommendedCategoryEntity.builder()
.spendingCategory("DAILY")
.recommendCategory("LIFE")
.build()
);
recommendRepository.saveAll(categories);
log.info("Recommended categories data initialized with {} records", categories.size());
}
}
@@ -24,7 +24,7 @@ import java.util.List;
public class SecurityConfig {
protected final JwtTokenProvider jwtTokenProvider;
@Value("${allowedorigins}")
@Value("${allowed-origins}")
private String allowedOrigins;
public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
@@ -25,7 +25,7 @@ import java.util.stream.Collectors;
public class JwtTokenProvider {
private final Algorithm algorithm;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) {
this.algorithm = Algorithm.HMAC512(secretKey);
}
@@ -4,6 +4,7 @@ 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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "구독추천 API", description = "구독추천 관련 API")
@RestController
@SecurityRequirement(name = "bearerAuth") //이 어노테이션이 없으면 요청 헤더에 Authorization헤더가 안 생김
@RequestMapping("/api/recommend")
@RequiredArgsConstructor
public class RecommendController {
@@ -8,6 +8,7 @@ import java.time.LocalDate;
@Builder
public class RecommendCategoryDTO {
private String categoryName;
private String imagePath;
private LocalDate baseDate;
private String spendingCategory;
private Long totalSpending;
}
@@ -1,19 +1,10 @@
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,16 @@
// File: lifesub/recommend/src/main/java/com/unicorn/lifesub/recommend/repository/jpa/SpendingRepository.java
package com.unicorn.lifesub.recommend.repository.jpa;
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;
public interface SpendingRepository extends JpaRepository<SpendingEntity, Long> {
@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);
}
@@ -1,3 +1,4 @@
// File: lifesub/recommend/src/main/java/com/unicorn/lifesub/recommend/service/RecommendServiceImpl.java
package com.unicorn.lifesub.recommend.service;
import com.unicorn.lifesub.common.exception.BusinessException;
@@ -6,44 +7,50 @@ 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 com.unicorn.lifesub.recommend.repository.jpa.SpendingRepository;
import com.unicorn.lifesub.recommend.repository.entity.SpendingEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RecommendServiceImpl implements RecommendService {
private final RecommendRepository recommendRepository;
private final SpendingRepository spendingRepository;
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)
);
List<SpendingEntity> spendings = spendingRepository.findSpendingsByUserIdAndDateAfter(userId, startDate);
if (spendings.isEmpty()) {
throw new BusinessException(ErrorCode.NO_SPENDING_DATA);
}
SpendingCategory topSpending = spendingAnalyzer.analyzeSpending(spendings);
if (topSpending == null) {
throw new BusinessException(ErrorCode.NO_SPENDING_DATA);
}
RecommendedCategory recommendedCategory = recommendRepository
.findBySpendingCategory(topSpending.getCategory())
.orElseThrow(() -> new BusinessException(ErrorCode.NO_SPENDING_DATA))
.toDomain();
.findBySpendingCategory(topSpending.getCategory())
.orElseThrow(() -> new BusinessException(ErrorCode.NO_RECOMMENDATION_DATA))
.toDomain();
return RecommendCategoryDTO.builder()
.categoryName(recommendedCategory.getRecommendCategory())
.imagePath(getCategoryImagePath(recommendedCategory.getRecommendCategory()))
.baseDate(recommendedCategory.getBaseDate())
.spendingCategory(topSpending.getCategory())
.totalSpending(topSpending.getTotalAmount())
.build();
}
private String getCategoryImagePath(String category) {
return "/images/categories/" + category.toLowerCase() + ".png";
}
}
}

Some files were not shown because too many files have changed in this diff Show More