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.

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'
}
}

View File

@ -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()")

View File

@ -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 {
}

View File

@ -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, "정의되지 않은 에러");

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:

View File

@ -2,8 +2,14 @@ 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);

View File

@ -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);

View File

@ -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) {

View File

@ -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;

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:

View File

@ -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;
}

View File

@ -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());
}
@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 "구독을 좋아하는 사람";
if (totalFee < 200000) return "구독 수집자";
return "구독 사치왕";
if (totalFee < 100000) return FeeLevel.LIKFER.getFeeLevel();
if (totalFee < 200000) return FeeLevel.COLLECTOR.getFeeLevel();
return FeeLevel.ADDICT.getFeeLevel();
}
}

View File

@ -0,0 +1 @@
com.unicorn.lifesub.mysub.infra.MySubApplication

View File

@ -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

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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")

View File

@ -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.*;

View File

@ -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;

View File

@ -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> {

View File

@ -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;

View File

@ -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;

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

View File

@ -0,0 +1 @@
com.unicorn.lifesub.recommend.RecommendApplication

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

View File

@ -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);

View File

@ -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());
}
}

View File

@ -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) {

View File

@ -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);
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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,16 +7,20 @@ 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
@ -23,27 +28,29 @@ public class RecommendServiceImpl implements RecommendService {
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