diff --git a/.gradle/8.4/checksums/checksums.lock b/.gradle/8.4/checksums/checksums.lock index 4b1cc6c..a40b6ec 100644 Binary files a/.gradle/8.4/checksums/checksums.lock and b/.gradle/8.4/checksums/checksums.lock differ diff --git a/.gradle/8.4/checksums/md5-checksums.bin b/.gradle/8.4/checksums/md5-checksums.bin index 3b13af4..c4cd60f 100644 Binary files a/.gradle/8.4/checksums/md5-checksums.bin and b/.gradle/8.4/checksums/md5-checksums.bin differ diff --git a/.gradle/8.4/checksums/sha1-checksums.bin b/.gradle/8.4/checksums/sha1-checksums.bin index d01caaa..d218812 100644 Binary files a/.gradle/8.4/checksums/sha1-checksums.bin and b/.gradle/8.4/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.4/dependencies-accessors/dependencies-accessors.lock b/.gradle/8.4/dependencies-accessors/dependencies-accessors.lock index 5a46d6d..d286ed7 100644 Binary files a/.gradle/8.4/dependencies-accessors/dependencies-accessors.lock and b/.gradle/8.4/dependencies-accessors/dependencies-accessors.lock differ diff --git a/.gradle/8.4/executionHistory/executionHistory.bin b/.gradle/8.4/executionHistory/executionHistory.bin index 958cbba..13b10dd 100644 Binary files a/.gradle/8.4/executionHistory/executionHistory.bin and b/.gradle/8.4/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.4/executionHistory/executionHistory.lock b/.gradle/8.4/executionHistory/executionHistory.lock index 10677fd..2373254 100644 Binary files a/.gradle/8.4/executionHistory/executionHistory.lock and b/.gradle/8.4/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.4/fileHashes/fileHashes.bin b/.gradle/8.4/fileHashes/fileHashes.bin index cbcdb5f..0528af3 100644 Binary files a/.gradle/8.4/fileHashes/fileHashes.bin and b/.gradle/8.4/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.4/fileHashes/fileHashes.lock b/.gradle/8.4/fileHashes/fileHashes.lock index 7f47bcf..0ec05f6 100644 Binary files a/.gradle/8.4/fileHashes/fileHashes.lock and b/.gradle/8.4/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.4/fileHashes/resourceHashesCache.bin b/.gradle/8.4/fileHashes/resourceHashesCache.bin index 4396a36..e55db9c 100644 Binary files a/.gradle/8.4/fileHashes/resourceHashesCache.bin and b/.gradle/8.4/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 921420d..701d69c 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index c3a1415..06e466b 100644 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe index 8966ed7..88c418f 100644 Binary files a/.gradle/file-system.probe and b/.gradle/file-system.probe differ diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 507bcb4..a49a46e 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -2,6 +2,7 @@ + diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..e757f2e --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 696ab65..04b2d1f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'org.springframework.boot' version '3.4.0' apply false - id 'io.spring.dependency-management' version '1.1.6' apply false + //id 'io.spring.dependency-management' version '1.1.6' apply false id 'java' } @@ -50,6 +50,8 @@ configure(subprojects.findAll { !it.name.endsWith('-biz') && it.name != 'common' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // AOP: 로깅 처리 자동화를 위해 사용 + implementation 'org.springframework.boot:spring-boot-starter-aop' // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' } diff --git a/common/build/libs/common-1.0.0-plain.jar b/common/build/libs/common-1.0.0-plain.jar index 1b5f1f2..e97c6e6 100644 Binary files a/common/build/libs/common-1.0.0-plain.jar and b/common/build/libs/common-1.0.0-plain.jar differ diff --git a/design/구독추천/API설계서 b/design/구독추천/API설계서 new file mode 100644 index 0000000..d1f8300 --- /dev/null +++ b/design/구독추천/API설계서 @@ -0,0 +1,18 @@ +서비스명|구독추천 +마이크로서비스 이름|SubRecommend +유저스토리 ID|RSS-005 +유저스토리 제목|추천 구독 카테고리 +Controller 이름|RecommendController +API 목적|사용자의 지출 패턴을 분석하여 추천 구독 카테고리 제공 +API Method|GET +API 그룹 Path|/api/recommend +API Path|/categories +Path <변수유형> <변수명>| +Query Key|userId +Query <변수유형> <변수명>|String userId +Request DTO 이름| +Request DTO 배열 여부| +Request DTO 구조| +Response DTO 이름|RecommendCategoryDTO +Response DTO 배열 여부|No +Response DTO 구조|String categoryName; LocalDate baseDate; String spendingCategory; Long totalSpending \ No newline at end of file diff --git a/design/구독추천/데이터설계서 b/design/구독추천/데이터설계서 new file mode 100644 index 0000000..d6e6d33 --- /dev/null +++ b/design/구독추천/데이터설계서 @@ -0,0 +1,37 @@ +!theme mono +title Recommendation Service - 데이터 모델 + +' Style configurations +skinparam linetype ortho +hide circle + +entity "Spending_History" as spending { + * id: bigint <> + -- + userId: varchar(50) + category: varchar(50) + amount: decimal(15,2) + spendingDate: date + createdAt: timestamp +} + +entity "Recommended_Categories" as recommend { + * id: bigint <> + -- + spendingCategory: varchar(50) + recommendCategory: varchar(50) + createdAt: timestamp +} + +note right of spending + 사용자별 지출 이력 관리 + - 금액 + - 카테고리 + - 지출일자 +end note + +note right of recommend + 지출-구독 카테고리 매핑 + - 지출 카테고리 + - 추천 구독 카테고리 +end note \ No newline at end of file diff --git a/design/구독추천/시퀀스설계서 b/design/구독추천/시퀀스설계서 new file mode 100644 index 0000000..85a7c5a --- /dev/null +++ b/design/구독추천/시퀀스설계서 @@ -0,0 +1,54 @@ +!theme mono +title 구독추천 서비스 - 내부 시퀀스 다이어그램 + +actor Client +participant "추천 컨트롤러\n(RecommendController)" as Controller +participant "추천 서비스\n(RecommendService)" as Service +participant "지출분석 서비스\n(SpendingAnalyzer)" as Analyzer +database "구독추천 DB" as RecommendDB + +' 지출 기반 추천 카테고리 조회 +Client -> Controller: GET /api/recommend/categories\n[지출 기반 추천 카테고리 조회] +activate Controller + +Controller -> Service: getRecommendedCategory(userId) +activate Service + +' 사용자 지출 패턴 분석 +Service -> Analyzer: analyzeSpending(spendings) +activate Analyzer +Analyzer -> RecommendDB: findSpendingsByUserIdAndDateAfter(userId, startDate) +RecommendDB --> Analyzer: List +Analyzer -> Analyzer: calculateTotalByCategory() +Analyzer -> Analyzer: findTopCategory() +Analyzer --> Service: SpendingCategory +deactivate Analyzer + +' 추천 카테고리 매핑 +Service -> RecommendDB: findBySpendingCategory(topSpendingCategory) +RecommendDB --> Service: RecommendedCategory + +' 추천 결과 반환 +Service --> Controller: RecommendCategoryDTO +Controller --> Client: HTTP Response\n(recommendCategory, spendingCategory, baseDate) + +deactivate Service +deactivate Controller + +note right of Controller + 1. 요청 파라미터 검증 + 2. 서비스 계층 호출 + 3. 응답 변환 +end note + +note right of Service + 1. 지출 패턴 분석 요청 + 2. 추천 카테고리 매핑 + 3. 추천 결과 생성 +end note + +note right of Analyzer + 1. 최근 1개월 지출 데이터 조회 + 2. 카테고리별 지출 합산 + 3. 최고 지출 카테고리 도출 +end note \ No newline at end of file diff --git a/design/구독추천/클래스설계서 b/design/구독추천/클래스설계서 new file mode 100644 index 0000000..74bbb10 --- /dev/null +++ b/design/구독추천/클래스설계서 @@ -0,0 +1,81 @@ +!theme mono +title Recommendation Service - Class Diagram + +package "com.unicorn.lifesub.recommend" { + package "domain" { + class SpendingCategory { + -category: String + -totalAmount: Long + } + + class RecommendedCategory { + -spendingCategory: String + -recommendCategory: String + -baseDate: LocalDate + } + } + + package "service" { + interface RecommendService { + +getRecommendedCategory(userId: String): RecommendCategoryDTO + } + + class RecommendServiceImpl { + -recommendRepository: RecommendRepository + -spendingRepository: SpendingRepository + -spendingAnalyzer: SpendingAnalyzer + +getRecommendedCategory(userId: String): RecommendCategoryDTO + } + + class SpendingAnalyzer { + +analyzeSpending(spendings: List): SpendingCategory + -calculateTotalByCategory(spendings: List): Map + -findTopCategory(totals: Map): SpendingCategory + } + } + + package "controller" { + class RecommendController { + -recommendService: RecommendService + +getRecommendedCategory(userId: String): ResponseEntity> + } + } + + package "dto" { + class RecommendCategoryDTO { + -categoryName: String + -baseDate: LocalDate + -spendingCategory: String + -totalSpending: Long + } + } + + package "repository" { + package "jpa" { + interface SpendingRepository { + +findSpendingsByUserIdAndDateAfter(userId: String, startDate: LocalDate): List + } + + interface RecommendRepository { + +findBySpendingCategory(category: String): Optional + } + } + + package "entity" { + class SpendingEntity { + -id: Long + -userId: String + -category: String + -amount: Long + -spendingDate: LocalDate + } + + class RecommendedCategoryEntity { + -id: Long + -spendingCategory: String + -recommendCategory: String + +toDomain(): RecommendedCategory + } + } + } +} \ No newline at end of file diff --git a/design/논리아키텍처 b/design/논리아키텍처 new file mode 100644 index 0000000..aa83612 --- /dev/null +++ b/design/논리아키텍처 @@ -0,0 +1,78 @@ +!theme mono + +title 구독관리 서비스 - 논리 아키텍처 + +' Components +package "클라이언트 계층" { + [모바일/웹 앱] as App +} + +package "회원 서비스" { + [회원 컨트롤러] as MemberController + [회원 서비스] as MemberService + [JWT 토큰 제공자] as JwtTokenProvider + database "회원 DB" as MemberDB + + note right of MemberService + 1. 로그인/로그아웃 처리 + 2. JWT 토큰 생성/검증 + end note +} + +package "마이구독 서비스" { + [마이구독 컨트롤러] as MySubController + [카테고리 컨트롤러] as CategoryController + [서비스 컨트롤러] as ServiceController + [마이구독 서비스] as MySubService + database "마이구독 DB" as MySubDB { + [사용자별 구독 정보] + [구독 서비스 정보] + [카테고리 정보] + } + + note right of MySubService + 4. 총 구독료 계산 + 5. 나의 구독 목록 관리 + 8. 구독 상세 정보 제공 + 9. 구독 신청 처리 + 10. 구독 취소 처리 + 11. 구독 카테고리 관리 + 12. 카테고리별 구독 서비스 제공 + end note +} + +package "구독추천 서비스" { + [구독추천 컨트롤러] as RecommendController + [구독추천 서비스] as RecommendService + [지출분석 서비스] as SpendingAnalyzer + database "구독추천 DB" as RecommendDB { + [지출 이력] + [추천 카테고리] + } + + note right of RecommendService + 6. 지출 카테고리 기반 구독 추천 + - 사용자의 지출 패턴 분석 + - 최적 구독 카테고리 추천 + end note +} + +' Relationships +App --> MemberController +App --> MySubController +App --> CategoryController +App --> ServiceController +App --> RecommendController + +MemberController --> MemberService +MemberService --> JwtTokenProvider +MemberService --> MemberDB + +MySubController --> MySubService +CategoryController --> MySubService +ServiceController --> MySubService +MySubService --> MySubDB + +RecommendController --> RecommendService +RecommendService --> SpendingAnalyzer +RecommendService --> RecommendDB \ No newline at end of file diff --git a/design/마이구독/API설계서 b/design/마이구독/API설계서 new file mode 100644 index 0000000..7015adc --- /dev/null +++ b/design/마이구독/API설계서 @@ -0,0 +1,18 @@ +서비스명|마이구독|마이구독|마이구독|마이구독|마이구독|마이구독|마이구독 +마이크로서비스 이름|MySubscription|MySubscription|MySubscription|MySubscription|MySubscription|MySubscription|MySubscription +유저스토리 ID|MSS-005|MSS-010|MSS-020|MSS-025|MSS-030|MSS-035|MSS-040 +유저스토리 제목|총 구독료 표시|나의 구독 목록|구독상세|구독하기|구독취소|구독 카테고리 표시|구독서비스 목록 +Controller 이름|MySubController|MySubController|ServiceController|ServiceController|ServiceController|CategoryController|CategoryController +API 목적|총 구독료 조회|구독 목록 조회|구독 상세 조회|구독 신청|구독 취소|전체 카테고리 목록 조회|카테고리별 서비스 목록 조회 +API Method|GET|GET|GET|POST|DELETE|GET|GET +API 그룹 Path|/api/mysub|/api/mysub|/api/mysub/services|/api/mysub/services|/api/mysub/services|/api/mysub|/api/mysub +API Path|/total-fee|/list|/{subscriptionId}|/{subscriptionId}/subscribe|/{subscriptionId}|/categories|/services +Path <변수유형> <변수명>|||Long subscriptionId|Long subscriptionId|Long subscriptionId|| +Query Key|userId|userId||userId|||categoryId +Query <변수유형> <변수명>|String userId|String userId||String userId|||String categoryId +Request DTO 이름||||||| +Request DTO 배열 여부||||||| +Request DTO 구조||||||| +Response DTO 이름|TotalFeeResponse|MySubResponse|SubDetailResponse||null|CategoryResponse|ServiceListResponse +Response DTO 배열 여부|No|Yes|No||No|Yes|Yes +Response DTO 구조|Long totalFee; String feeLevel|String serviceName; String logoUrl|String serviceName; String logoUrl; String category; String description; int price; int maxSharedUsers||null|String categoryId; String categoryName|String serviceId; String serviceName; String description; int price; String logoUrl \ No newline at end of file diff --git a/design/마이구독/데이터설계서 b/design/마이구독/데이터설계서 new file mode 100644 index 0000000..b5eb484 --- /dev/null +++ b/design/마이구독/데이터설계서 @@ -0,0 +1,52 @@ +!theme mono +title MySubscription Service - 데이터 모델 + +' Style configurations +skinparam linetype ortho +hide circle + +entity "My_Subscriptions" as my_subs { + * id: bigint <> + -- + userId: varchar(50) + subscriptionId: bigint <> + createdAt: timestamp + updatedAt: timestamp +} + +entity "Subscriptions" as subs { + * id: bigint <> + -- + name: varchar(100) + description: text + category: varchar(50) + price: decimal(15,2) + maxSharedUsers: integer + logoUrl: varchar(255) + createdAt: timestamp + updatedAt: timestamp +} + +entity "Categories" as categories { + * categoryId: varchar(50) <> + -- + name: varchar(100) + createdAt: timestamp + updatedAt: timestamp +} + +' Relationships +my_subs }o--|| subs : subscriptionId +subs }o--|| categories : category-categoryId + +note right of my_subs + 사용자별 구독 정보 관리 +end note + +note right of subs + 구독 서비스 정보 관리 +end note + +note right of categories + 구독 카테고리 관리 +end note \ No newline at end of file diff --git a/design/마이구독/시퀀스설계서 b/design/마이구독/시퀀스설계서 new file mode 100644 index 0000000..6841e4d --- /dev/null +++ b/design/마이구독/시퀀스설계서 @@ -0,0 +1,101 @@ +!theme mono +title 마이구독 서비스 - 내부 시퀀스 다이어그램 + +actor Client +participant "마이구독 컨트롤러\n(MySubController)" as MySubController +participant "서비스 컨트롤러\n(ServiceController)" as ServiceController +participant "카테고리 컨트롤러\n(CategoryController)" as CategoryController +participant "마이구독 서비스\n(MySubService)" as Service +database "마이구독 DB" as MySubDB +database "구독서비스 DB" as SubServiceDB + +' 총 구독료 조회 +Client -> MySubController: GET /api/mysub/total-fee\n[총 구독료 조회] +activate MySubController +MySubController -> Service: getTotalFee(userId) +activate Service +Service -> MySubDB: findMySubscriptions(userId) +MySubDB --> Service: List +Service -> SubServiceDB: findSubscriptionsByIds(subIds) +SubServiceDB --> Service: List +Service --> MySubController: TotalFeeResponse +MySubController --> Client: HTTP Response\n(total fee, level) +deactivate Service +deactivate MySubController + +' 나의 구독 목록 조회 +Client -> MySubController: GET /api/mysub/list\n[구독 목록 조회] +activate MySubController +MySubController -> Service: getMySubscriptions(userId) +activate Service +Service -> MySubDB: findMySubscriptions(userId) +MySubDB --> Service: List +Service --> MySubController: List +MySubController --> Client: HTTP Response\n(subscription list) +deactivate Service +deactivate MySubController + +' 구독 상세 조회 +Client -> ServiceController: GET /api/mysub/services/{subscriptionId}\n[구독 상세 조회] +activate ServiceController +ServiceController -> Service: getSubscriptionDetail(subscriptionId) +activate Service +Service -> SubServiceDB: findById(subscriptionId) +SubServiceDB --> Service: Subscription +Service --> ServiceController: SubDetailResponse +ServiceController --> Client: HTTP Response\n(subscription detail) +deactivate Service +deactivate ServiceController + +' 구독 신청 +Client -> ServiceController: POST /api/mysub/services/{subscriptionId}/subscribe\n[구독 신청] +activate ServiceController +ServiceController -> Service: subscribe(subscriptionId, userId) +activate Service +Service -> SubServiceDB: findById(subscriptionId) +Service -> MySubDB: save(mySubscription) +Service --> ServiceController: void +ServiceController --> Client: HTTP Response\n(success) +deactivate Service +deactivate ServiceController + +' 구독 취소 +Client -> ServiceController: DELETE /api/mysub/services/{subscriptionId}\n[구독 취소] +activate ServiceController +ServiceController -> Service: cancel(subscriptionId) +activate Service +Service -> MySubDB: delete(subscriptionId) +Service --> ServiceController: void +ServiceController --> Client: HTTP Response\n(success) +deactivate Service +deactivate ServiceController + +' 카테고리 목록 조회 +Client -> CategoryController: GET /api/mysub/categories\n[카테고리 목록 조회] +activate CategoryController +CategoryController -> Service: getAllCategories() +activate Service +Service -> SubServiceDB: findAllCategories() +SubServiceDB --> Service: List +Service --> CategoryController: List +CategoryController --> Client: HTTP Response\n(category list) +deactivate Service +deactivate CategoryController + +' 카테고리별 서비스 목록 조회 +Client -> CategoryController: GET /api/mysub/services\n[카테고리별 서비스 목록 조회] +activate CategoryController +CategoryController -> Service: getServicesByCategory(categoryId) +activate Service +Service -> SubServiceDB: findByCategory(categoryId) +SubServiceDB --> Service: List +Service --> CategoryController: List +CategoryController --> Client: HTTP Response\n(service list) +deactivate Service +deactivate CategoryController + +note right of Service + 1. 비즈니스 로직 처리 + 2. 구독 관리 + 3. 카테고리 관리 +end note \ No newline at end of file diff --git a/design/마이구독/클래스설계서 b/design/마이구독/클래스설계서 new file mode 100644 index 0000000..495a3ec --- /dev/null +++ b/design/마이구독/클래스설계서 @@ -0,0 +1,260 @@ +!theme mono +title MySubscription Service - Clean Architecture Class Diagram + +' BIZ Layer +package "com.unicorn.lifesub.mysub.biz" { + package "usecase" { + package "in" { + interface TotalFeeUseCase { + +getTotalFee(userId: String): TotalFeeResponse + } + + interface MySubscriptionsUseCase { + +getMySubscriptions(userId: String): List + } + + interface SubscriptionDetailUseCase { + +getSubscriptionDetail(subscriptionId: Long): SubDetailResponse + } + + interface SubscribeUseCase { + +subscribe(subscriptionId: Long, userId: String): void + } + + interface CancelSubscriptionUseCase { + +cancel(subscriptionId: Long): void + } + + interface CategoryUseCase { + +getAllCategories(): List + +getServicesByCategory(categoryId: String): List + } + } + + package "out" { + interface MySubscriptionReader { + +findByUserId(userId: String): List + +findById(id: Long): Optional + } + + interface MySubscriptionWriter { + +save(userId: String, subscriptionId: Long): MySubscription + +delete(id: Long): void + } + + interface SubscriptionReader { + +findById(id: Long): Optional + +findByCategory(category: String): List + +findAllCategories(): List + } + } + } + + package "domain" { + class MySubscription { + -id: Long + -userId: String + -subscription: Subscription + +getPrice(): int + } + + class Subscription { + -id: Long + -name: String + -description: String + -category: String + -price: int + -maxSharedUsers: int + -logoUrl: String + } + + class Category { + -categoryId: String + -name: String + } + } + + package "service" { + class MySubscriptionService { + -mySubscriptionReader: MySubscriptionReader + -mySubscriptionWriter: MySubscriptionWriter + -subscriptionReader: SubscriptionReader + -collectorThreshold: long + -addictThreshold: long + +getTotalFee(userId: String): TotalFeeResponse + +getMySubscriptions(userId: String): List + +getSubscriptionDetail(subscriptionId: Long): SubDetailResponse + +subscribe(subscriptionId: Long, userId: String): void + +cancel(subscriptionId: Long): void + +getAllCategories(): List + +getServicesByCategory(categoryId: String): List + -calculateFeeLevel(totalFee: long): String + } + + enum FeeLevel { + LIKFER + COLLECTOR + ADDICT + } + } + + package "dto" { + class TotalFeeResponse { + -totalFee: Long + -feeLevel: String + } + + class MySubResponse { + -id: Long + -serviceName: String + -logoUrl: String + } + + class SubDetailResponse { + -serviceName: String + -logoUrl: String + -category: String + -description: String + -price: int + -maxSharedUsers: int + } + + class CategoryResponse { + -categoryId: String + -categoryName: String + } + + class ServiceListResponse { + -serviceId: String + -serviceName: String + -description: String + -price: int + -logoUrl: String + } + } +} + +' INFRA Layer +package "com.unicorn.lifesub.mysub.infra" { + package "controller" { + class MySubController { + -totalFeeUseCase: TotalFeeUseCase + -mySubscriptionsUseCase: MySubscriptionsUseCase + +getTotalFee(userId: String): ResponseEntity> + +getMySubscriptions(userId: String): ResponseEntity>> + } + + class ServiceController { + -subscriptionDetailUseCase: SubscriptionDetailUseCase + -subscribeUseCase: SubscribeUseCase + -cancelSubscriptionUseCase: CancelSubscriptionUseCase + +getSubscriptionDetail(subscriptionId: Long): ResponseEntity> + +subscribe(subscriptionId: Long, userId: String): ResponseEntity> + +cancel(subscriptionId: Long): ResponseEntity> + } + + class CategoryController { + -categoryUseCase: CategoryUseCase + +getAllCategories(): ResponseEntity>> + +getServicesByCategory(categoryId: String): ResponseEntity>> + } + } + + package "gateway" { + class MySubscriptionGateway implements MySubscriptionReader, MySubscriptionWriter { + -mySubscriptionRepository: MySubscriptionJpaRepository + -subscriptionRepository: SubscriptionJpaRepository + } + + class SubscriptionGateway implements SubscriptionReader { + -subscriptionRepository: SubscriptionJpaRepository + -categoryRepository: CategoryJpaRepository + } + + package "repository" { + interface MySubscriptionJpaRepository { + +findByUserId(userId: String): List + +findBySubscription_Id(subscriptionId: Long): Optional + } + + interface SubscriptionJpaRepository { + +findById(id: Long): Optional + +findByCategory(category: String): List + } + + interface CategoryJpaRepository { + +findAll(): List + } + } + + package "entity" { + class MySubscriptionEntity { + -id: Long + -userId: String + -subscription: SubscriptionEntity + +toDomain(): MySubscription + } + + class SubscriptionEntity { + -id: Long + -name: String + -description: String + -category: String + -price: int + -maxSharedUsers: int + -logoUrl: String + +toDomain(): Subscription + } + + class CategoryEntity { + -categoryId: String + -name: String + +toDomain(): Category + } + } + } + + package "config" { + class SecurityConfig { + -jwtTokenProvider: JwtTokenProvider + +securityFilterChain(http: HttpSecurity): SecurityFilterChain + +corsConfigurationSource(): CorsConfigurationSource + } + + class SwaggerConfig { + +openAPI(): OpenAPI + } + + class DataLoader { + -categoryRepository: CategoryJpaRepository + -subscriptionRepository: SubscriptionJpaRepository + +run(): void + } + } +} + +' Relationships +MySubscriptionService ..|> TotalFeeUseCase +MySubscriptionService ..|> MySubscriptionsUseCase +MySubscriptionService ..|> SubscriptionDetailUseCase +MySubscriptionService ..|> SubscribeUseCase +MySubscriptionService ..|> CancelSubscriptionUseCase +MySubscriptionService ..|> CategoryUseCase + +MySubController --> TotalFeeUseCase +MySubController --> MySubscriptionsUseCase +ServiceController --> SubscriptionDetailUseCase +ServiceController --> SubscribeUseCase +ServiceController --> CancelSubscriptionUseCase +CategoryController --> CategoryUseCase + +MySubscriptionGateway --> MySubscriptionJpaRepository +MySubscriptionGateway --> SubscriptionJpaRepository +SubscriptionGateway --> SubscriptionJpaRepository +SubscriptionGateway --> CategoryJpaRepository + +MySubscriptionEntity ..> MySubscription : converts to +SubscriptionEntity ..> Subscription : converts to +CategoryEntity ..> Category : converts to + +MySubscription --> Subscription \ No newline at end of file diff --git a/design/물리아키텍처 b/design/물리아키텍처 new file mode 100644 index 0000000..8bbdd24 --- /dev/null +++ b/design/물리아키텍처 @@ -0,0 +1,74 @@ +!theme mono +title ResourceGroup - 물리아키텍처 + +' Azure Resource Group +rectangle "ResourceGroup" { + ' Virtual Network + rectangle "VirtualNetwork" { + ' AKS Cluster + rectangle "AKSCluster" { + rectangle "SystemNodePool" { + [IngressController] as ingress + } + + rectangle "UserNodePool" { + rectangle "MemberServicePod" { + [회원서비스] as memberservice + } + + rectangle "MySubscriptionServicePod" { + [마이구독서비스] as mysubservice + } + + rectangle "RecommendServicePod" { + [구독추천서비스] as recommendservice + } + } + } + } + + ' Managed Databases + database "AzureDatabasePostgreSQL" { + database "MemberDB" as memberdb { + [Members] + } + + database "MySubscriptionDB" as mysubdb { + [MySubscriptions] + [Subscriptions] + [Categories] + } + + database "RecommendDB" as recommenddb { + [SpendingHistory] + [RecommendedCategories] + } + } +} + +' External Actors +actor "Client" as client +actor "Developer" as developer + +' Network Flow +client --> ingress +ingress --> memberservice +ingress --> mysubservice +ingress --> recommendservice + +' Database Connections +memberservice --> memberdb +mysubservice --> mysubdb +recommendservice --> recommenddb + +' Development Access +developer --> ingress : "kubectl" + +' Legend +legend right +| Component | Description | +|---|---| +| Infrastructure | AKSCluster | +| Services | Member, MySubscription, Recommend | +| Databases | PostgreSQL(Member, MySubscription, Recommend) | +endlegend \ No newline at end of file diff --git a/design/시퀀스설계서(외부) b/design/시퀀스설계서(외부) new file mode 100644 index 0000000..7992784 --- /dev/null +++ b/design/시퀀스설계서(외부) @@ -0,0 +1,42 @@ +!theme mono +title 구독관리 서비스 - 외부 시퀀스 다이어그램 + +actor Client +participant "회원 서비스" as MemberService +participant "마이구독 서비스" as MySubService +participant "구독추천 서비스" as RecommendService + +' 회원 서비스 호출 +Client -> MemberService: POST /api/auth/login\n[로그인] +Client -> MemberService: POST /api/auth/logout\n[로그아웃] + +' 마이구독 서비스 호출 +Client -> MySubService: GET /api/mysub/total-fee\n[총 구독료 조회] +Client -> MySubService: GET /api/mysub/list\n[구독 목록 조회] +Client -> MySubService: GET /api/mysub/categories\n[전체 카테고리 목록 조회] +Client -> MySubService: GET /api/mysub/services\n[카테고리별 서비스 목록 조회] +Client -> MySubService: GET /api/mysub/services/{subscriptionId}\n[구독 상세 조회] +Client -> MySubService: POST /api/mysub/services/{subscriptionId}/subscribe\n[구독 신청] +Client -> MySubService: DELETE /api/mysub/services/{subscriptionId}\n[구독 취소] + +' 구독추천 서비스 호출 +Client -> RecommendService: GET /api/recommend/categories\n[지출 기반 추천 카테고리 조회] + +note right of MemberService + 인증/인가 처리 + - JWT 기반 토큰 인증 + - Role 기반 권한 관리 +end note + +note right of MySubService + 구독 서비스 관리 + - 구독 정보 관리 + - 카테고리별 서비스 제공 + - 구독료 계산 +end note + +note right of RecommendService + 지출 분석 기반 추천 + - 사용자별 지출 패턴 분석 + - 최적 구독 카테고리 추천 +end note \ No newline at end of file diff --git a/design/회원/API설계서 b/design/회원/API설계서 new file mode 100644 index 0000000..9936133 --- /dev/null +++ b/design/회원/API설계서 @@ -0,0 +1,18 @@ +서비스명|회원|회원 +마이크로서비스 이름|Member|Member +유저스토리 ID|USR-005|USR-015 +유저스토리 제목|로그인|로그아웃 +Controller 이름|MemberController|MemberController +API 목적|사용자 로그인|로그아웃 +API Method|POST|POST +API 그룹 Path|/api/auth|/api/auth +API Path|/login|/logout +Path <변수유형> <변수명>|| +Query Key|| +Query <변수유형> <변수명>|| +Request DTO 이름|LoginRequest|LogoutRequest +Request DTO 배열 여부|No|No +Request DTO 구조|String userId; String password|String userId +Response DTO 이름|JwtTokenDTO|LogoutResponse +Response DTO 배열 여부|No|No +Response DTO 구조|String accessToken; String refreshToken|String message \ No newline at end of file diff --git a/design/회원/데이터설계서 b/design/회원/데이터설계서 new file mode 100644 index 0000000..9cfbe96 --- /dev/null +++ b/design/회원/데이터설계서 @@ -0,0 +1,17 @@ +!theme mono +title Member Service - 데이터 모델 + +entity "Members" as members { + * userId: varchar(50) <> + -- + userName: varchar(100) + password: varchar(255) + roles: varchar(255) + createdAt: timestamp + updatedAt: timestamp +} + +note right of members + roles는 ARRAY 또는 JSON 타입으로 + ['USER', 'ADMIN'] 형태로 저장 +end note \ No newline at end of file diff --git a/design/회원/시퀀스설계서 b/design/회원/시퀀스설계서 new file mode 100644 index 0000000..3e7f083 --- /dev/null +++ b/design/회원/시퀀스설계서 @@ -0,0 +1,72 @@ +!theme mono +title 회원 서비스 - 내부 시퀀스 다이어그램 + +actor Client +participant "회원 컨트롤러\n(MemberController)" as Controller +participant "회원 서비스\n(MemberService)" as Service +participant "JWT 토큰 제공자\n(JwtTokenProvider)" as TokenProvider +participant "비밀번호 인코더\n(PasswordEncoder)" as PwEncoder +database "회원 DB" as DB + +' 로그인 flow +Client -> Controller: POST /api/auth/login\n[로그인] +activate Controller + +Controller -> Service: login(LoginRequest) +activate Service + +Service -> DB: findByUserId(userId) +activate DB +DB --> Service: Member +deactivate DB + +Service -> PwEncoder: matches(rawPassword, encodedPassword) +activate PwEncoder +PwEncoder --> Service: matched result +deactivate PwEncoder + +alt 인증 성공 + Service -> TokenProvider: createToken(member) + activate TokenProvider + TokenProvider --> Service: access/refresh tokens + deactivate TokenProvider + + Service --> Controller: TokenResponse +else 인증 실패 + Service --> Controller: throw InvalidCredentialsException +end + +Controller --> Client: HTTP Response\n(tokens or error) +deactivate Service +deactivate Controller + +' 로그아웃 flow +Client -> Controller: POST /api/auth/logout\n[로그아웃] +activate Controller + +Controller -> Service: logout(LogoutRequest) +activate Service + +Service --> Controller: LogoutResponse +Controller --> Client: HTTP Response\n(success message) + +deactivate Service +deactivate Controller + +note right of Controller + 1. 요청 유효성 검증 + 2. 서비스 계층 호출 + 3. 응답 변환 및 반환 +end note + +note right of Service + 1. 비즈니스 로직 처리 + 2. 사용자 인증 + 3. 토큰 관리 +end note + +note right of TokenProvider + 1. JWT 토큰 생성 + 2. 토큰 검증 + 3. 토큰 무효화 +end note \ No newline at end of file diff --git a/design/회원/클래스설계서 b/design/회원/클래스설계서 new file mode 100644 index 0000000..0e4dc54 --- /dev/null +++ b/design/회원/클래스설계서 @@ -0,0 +1,89 @@ +!theme mono +title Member Service - Class Diagram + +package "com.unicorn.lifesub.member" { + package "domain" { + class Member { + -userId: String + -userName: String + -password: String + -roles: Set + +Member(userId: String, userName: String, password: String, roles: Set) + } + } + + package "service" { + interface MemberService { + +login(request: LoginRequest): JwtTokenDTO + +logout(request: LogoutRequest): LogoutResponse + } + + class MemberServiceImpl { + -memberRepository: MemberRepository + -passwordEncoder: PasswordEncoder + -jwtTokenProvider: JwtTokenProvider + +login(request: LoginRequest): JwtTokenDTO + +logout(request: LogoutRequest): LogoutResponse + } + } + + package "controller" { + class MemberController { + -memberService: MemberService + +login(request: LoginRequest): ResponseEntity> + +logout(request: LogoutRequest): ResponseEntity> + } + } + + package "dto" { + class LoginRequest { + -userId: String + -password: String + } + + class LogoutRequest { + -userId: String + } + + class LogoutResponse { + -message: String + } + } + + package "repository" { + package "jpa" { + interface MemberRepository { + +findByUserId(userId: String): Optional + } + } + + package "entity" { + class MemberEntity { + -userId: String + -userName: String + -password: String + -roles: Set + +toDomain(): Member + +fromDomain(member: Member): MemberEntity + } + } + } + + package "config" { + class SecurityConfig { + -jwtTokenProvider: JwtTokenProvider + +securityFilterChain(http: HttpSecurity): SecurityFilterChain + +corsConfigurationSource(): CorsConfigurationSource + +passwordEncoder(): PasswordEncoder + } + + class JwtTokenProvider { + -algorithm: Algorithm + -accessTokenValidityInMilliseconds: long + -refreshTokenValidityInMilliseconds: long + +createToken(member: MemberEntity): JwtTokenDTO + +validateToken(token: String): boolean + +getAuthentication(token: String): Authentication + } + } +} \ No newline at end of file diff --git a/member/build/classes/java/main/com/unicorn/lifesub/member/config/SecurityConfig.class b/member/build/classes/java/main/com/unicorn/lifesub/member/config/SecurityConfig.class index 8e7e97b..4b985a1 100644 Binary files a/member/build/classes/java/main/com/unicorn/lifesub/member/config/SecurityConfig.class and b/member/build/classes/java/main/com/unicorn/lifesub/member/config/SecurityConfig.class differ diff --git a/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity$MemberEntityBuilder.class b/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity$MemberEntityBuilder.class index b511535..3f3116e 100644 Binary files a/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity$MemberEntityBuilder.class and b/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity$MemberEntityBuilder.class differ diff --git a/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity.class b/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity.class index 1ba4f45..783d6b0 100644 Binary files a/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity.class and b/member/build/classes/java/main/com/unicorn/lifesub/member/repository/entity/MemberEntity.class differ diff --git a/member/build/libs/member-1.0.0-plain.jar b/member/build/libs/member-1.0.0-plain.jar new file mode 100644 index 0000000..8ee2971 Binary files /dev/null and b/member/build/libs/member-1.0.0-plain.jar differ diff --git a/member/build/libs/member.jar b/member/build/libs/member.jar new file mode 100644 index 0000000..eaae75a Binary files /dev/null and b/member/build/libs/member.jar differ diff --git a/member/build/tmp/bootJar/MANIFEST.MF b/member/build/tmp/bootJar/MANIFEST.MF new file mode 100644 index 0000000..d346304 --- /dev/null +++ b/member/build/tmp/bootJar/MANIFEST.MF @@ -0,0 +1,12 @@ +Manifest-Version: 1.0 +Main-Class: org.springframework.boot.loader.launch.JarLauncher +Start-Class: com.unicorn.lifesub.member.MemberApplication +Spring-Boot-Version: 3.4.0 +Spring-Boot-Classes: BOOT-INF/classes/ +Spring-Boot-Lib: BOOT-INF/lib/ +Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx +Spring-Boot-Layers-Index: BOOT-INF/layers.idx +Build-Jdk-Spec: 18 +Implementation-Title: member +Implementation-Version: 1.0.0 + diff --git a/member/build/tmp/compileJava/previous-compilation-data.bin b/member/build/tmp/compileJava/previous-compilation-data.bin index 89a38b4..9733c62 100644 Binary files a/member/build/tmp/compileJava/previous-compilation-data.bin and b/member/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/member/build/tmp/jar/MANIFEST.MF b/member/build/tmp/jar/MANIFEST.MF new file mode 100644 index 0000000..59499bc --- /dev/null +++ b/member/build/tmp/jar/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/member/src/main/java/com/unicorn/lifesub/member/config/SecurityConfig.java b/member/src/main/java/com/unicorn/lifesub/member/config/SecurityConfig.java index ce6da0d..db32ca8 100644 --- a/member/src/main/java/com/unicorn/lifesub/member/config/SecurityConfig.java +++ b/member/src/main/java/com/unicorn/lifesub/member/config/SecurityConfig.java @@ -24,9 +24,9 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; import java.util.List; -@Configuration -@EnableWebSecurity -@SuppressWarnings("unused") +@Configuration //Config 레이어의 클래스임을 나타내며 Bean클래스로 등록되어 실행시 자동으로 객체가 생성됨 +@EnableWebSecurity //인증 처리 라이브러리인 Spring Security를 활성화함 +@SuppressWarnings("unused") //unused 경고를 표시하지 않게 하는 어노테이션 public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; private final CustomUserDetailsService customUserDetailsService; @@ -44,6 +44,16 @@ public class SecurityConfig { return authConfig.getAuthenticationManager(); } + /* + 아래와 같은 수행을 함 + - CORS설정: 접근을 허용할 도메인, 메서드, 헤더값 등을 설정함 + - csrf : Cross Site Request Forgery(인증된 웹 세션을 사용하여 서버를 공격하는 행위)을 비활성화 + JWT 방식을 사용하므로 불필요함. 만약 CSRF까지 활성화하면 클라이언트는 CSRF토큰도 요청 헤더에 보내야 함 + - authorizeHttpRequests: 인증이 필요없는 주소를 지정하고, 나머지는 인증이 안되어 있으면 접근을 금지시킴 + swagger페이지와 로그인, 회원등록 페이지는 인증 안해도 접근하도록 설정함 + - sessionManagement: 세션을 로컬에 저장하지 않도록 함 + - userDetailsService: 사용자 인증을 처리할 class + */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http diff --git a/member/src/main/java/com/unicorn/lifesub/member/repository/entity/MemberEntity.java b/member/src/main/java/com/unicorn/lifesub/member/repository/entity/MemberEntity.java index 1d1c251..332495a 100644 --- a/member/src/main/java/com/unicorn/lifesub/member/repository/entity/MemberEntity.java +++ b/member/src/main/java/com/unicorn/lifesub/member/repository/entity/MemberEntity.java @@ -13,12 +13,16 @@ import java.util.Set; @Entity @Table(name = "members") @Getter -@NoArgsConstructor +@NoArgsConstructor //JPA는 인자없는 기본 생성자를 대부분 요구하기 때문에 필요 public class MemberEntity extends BaseTimeEntity { - @Id + @Id ////PK(primary key)필드로 지정 + @Column(name = "user_id", unique = true, nullable = false) //테이블 스키마 생성 시 필드명, 유일값 private String userId; - + + @Column(nullable = false) private String userName; + + @Column(nullable = false) private String password; @ElementCollection(fetch = FetchType.EAGER) diff --git a/mysub-biz/build/tmp/compileJava/previous-compilation-data.bin b/mysub-biz/build/tmp/compileJava/previous-compilation-data.bin index eea369b..607710d 100644 Binary files a/mysub-biz/build/tmp/compileJava/previous-compilation-data.bin and b/mysub-biz/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/mysub-infra/build/classes/java/main/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.class b/mysub-infra/build/classes/java/main/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.class index 4333dc5..e71f9dd 100644 Binary files a/mysub-infra/build/classes/java/main/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.class and b/mysub-infra/build/classes/java/main/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.class differ diff --git a/mysub-infra/build/resources/main/application.yml b/mysub-infra/build/resources/main/application.yml index efcb751..fa2d807 100644 --- a/mysub-infra/build/resources/main/application.yml +++ b/mysub-infra/build/resources/main/application.yml @@ -19,6 +19,7 @@ spring: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect +# Secret key 만들기: openssl rand -base64 32 jwt: secret-key: ${JWT_SECRET_KEY:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ} diff --git a/mysub-infra/build/tmp/compileJava/previous-compilation-data.bin b/mysub-infra/build/tmp/compileJava/previous-compilation-data.bin index 5dd08ba..d9d5dfb 100644 Binary files a/mysub-infra/build/tmp/compileJava/previous-compilation-data.bin and b/mysub-infra/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/mysub-infra/src/main/java/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.java b/mysub-infra/src/main/java/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.java index 1c3bd0e..7cc3af5 100644 --- a/mysub-infra/src/main/java/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.java +++ b/mysub-infra/src/main/java/com/unicorn/lifesub/mysub/infra/config/SecurityConfig.java @@ -31,6 +31,15 @@ public class SecurityConfig { this.jwtTokenProvider = jwtTokenProvider; } + /* + 아래와 같은 수행을 함 + - CORS설정: 접근을 허용할 도메인, 메서드, 헤더값 등을 설정함 + - csrf : Cross Site Request Forgery(인증된 웹 세션을 사용하여 서버를 공격하는 행위)을 비활성화 + JWT 방식을 사용하므로 불필요함. 만약 CSRF까지 활성화하면 클라이언트는 CSRF토큰도 요청 헤더에 보내야 함 + - authorizeHttpRequests: 인증이 필요없는 주소를 지정하고, 나머지는 인증이 안되어 있으면 접근을 금지시킴 + swagger페이지와 로그인, 회원등록 페이지는 인증 안해도 접근하도록 설정함 + - sessionManagement: 세션을 로컬에 저장하지 않도록 함 + */ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http diff --git a/mysub-infra/src/main/resources/application.yml b/mysub-infra/src/main/resources/application.yml index efcb751..fa2d807 100644 --- a/mysub-infra/src/main/resources/application.yml +++ b/mysub-infra/src/main/resources/application.yml @@ -19,6 +19,7 @@ spring: format_sql: true dialect: org.hibernate.dialect.PostgreSQLDialect +# Secret key 만들기: openssl rand -base64 32 jwt: secret-key: ${JWT_SECRET_KEY:8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ} diff --git a/recommend/build/tmp/compileJava/previous-compilation-data.bin b/recommend/build/tmp/compileJava/previous-compilation-data.bin index f717661..14e2bbe 100644 Binary files a/recommend/build/tmp/compileJava/previous-compilation-data.bin and b/recommend/build/tmp/compileJava/previous-compilation-data.bin differ