diff --git a/.gitignore b/.gitignore index cfcf193..ed7becb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ out/ /dist/ /nbdist/ /.nb-gradle/ +.DS_Store ### VS Code ### .vscode/ diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..66c6fed --- /dev/null +++ b/prompt.md @@ -0,0 +1,301 @@ +## 아키텍처 및 설계 원칙 + +### 기본 원칙 +- 도메인 주도 개발(DDD)로 개발해야함 +- 헥사고날 아키텍처를 준수해야함 +- N+1 문제와 같은 오버헤드를 방지해야함 +- 모든 코드는 객체지향 원칙을 준수해야함 +- 모든 코드는 객체지향 생활체조 원칙도 준수해야함 +- 함수의 최대 depth는 2임 +- 모든 코드는 최대한 nullable하지 않게 작성해야함 +- Kotlin의 null safety를 활용하고, `@Nonnull` 같은 어노테이션을 사용하지 않음 + +### CQRS 패턴 +- Command와 Query를 명확하게 분리해야함 +- Controller: `{Domain}CommandController`, `{Domain}QueryController`로 분리 +- Service: `{Domain}CommandService`, `{Domain}QueryService`로 분리 +- Command는 상태 변경, Query는 조회만 담당 + +## 패키지 구조 + +### 도메인 계층 구조 +``` +domain/ + {domain}/ + application/ # 애플리케이션 계층 + dto/ + request/ # 요청 DTO (infrastructure에서 사용) + response/ # 응답 DTO (application에서 사용) + exception/ # 애플리케이션 레벨 예외 + mapper/ # Entity <-> DTO 변환 + service/ # 비즈니스 로직 + domain/ # 도메인 계층 + {Entity}.kt # 도메인 엔티티 + vo/ # Value Object + type/ # Enum 타입 + exception/ # 도메인 레벨 예외 + presentation/ # 프레젠테이션 계층 + {Domain}CommandController.kt + {Domain}QueryController.kt + repository/ # 저장소 계층 + {Domain}Repository.kt # 인터페이스 + {Domain}RepositoryImpl.kt # 구현체 + {Domain}PersistenceRepository.kt # JPA Repository +``` + +### 계층별 책임 +- **도메인 계층 (domain/)**: 엔티티, VO, 도메인 예외, 도메인 규칙 +- **애플리케이션 계층 (application/)**: DTO, 매퍼, 서비스, 애플리케이션 예외 +- **프레젠테이션 계층 (presentation/)**: 컨트롤러 +- **인프라 계층 (infrastructure/)**: 외부 API 클라이언트, 어댑터 + +### Global 계층 +``` +global/ + aspect/ # AOP + config/ # 설정 클래스 + error/ # 글로벌 에러 처리 + properties/ # 프로퍼티 + security/ # 보안 설정 + util/ # 유틸리티 +``` + +## 네이밍 컨벤션 + +### 파일 네이밍 +- Entity: `{Domain}.kt` (예: `Item.kt`, `User.kt`) +- VO: `{Domain}{Purpose}.kt` (예: `ItemInfo.kt`, `Stock.kt`, `UserAccountInfo.kt`) +- DTO: `{Domain}{Action}Request/Response.kt` (예: `ItemUpdateRequest.kt`, `ItemResponse.kt`) +- Service: `{Domain}CommandService.kt`, `{Domain}QueryService.kt` +- Controller: `{Domain}CommandController.kt`, `{Domain}QueryController.kt` +- Repository: `{Domain}Repository.kt`, `{Domain}RepositoryImpl.kt`, `{Domain}PersistenceRepository.kt` +- Exception: `{Purpose}Exception.kt` (예: `ItemNotFoundException.kt`, `ItemStockNegativeException.kt`) +- Mapper: `{Domain}Mapper.kt` (object로 구현) + +### 변수 네이밍 +- 모든 변수는 camelCase 사용 +- private 필드는 `private val/var {fieldName}` 형식 +- Boolean 타입은 `is{Something}` 형식 (예: `isDeleted`) + +### 함수 네이밍 +- 조회: `query{Something}`, `find{Something}`, `get{Something}` +- 생성: `create{Something}`, `save{Something}` +- 수정: `update{Something}`, `modify{Something}` +- 삭제: `delete{Something}`, `deactivate{Something}` (소프트 삭제) +- 검증: `validate{Something}`, `ensure{Something}` +- 변환: `to{Something}`, `map{Something}` + +## Entity 작성 규칙 + +### 기본 구조 +```kotlin +@Entity +@Table(name = "table_name") +data class Entity( + @Id + @field:Column(name = "id_column") + private val id: Long = 0L, + + @Embedded + private var vo: ValueObject, + + private var isDeleted: Boolean = false, +) { + // 비즈니스 로직 메서드 + fun businessLogic() { + // 로직 + } + + // Getter 메서드 (필드명과 동일) + fun id() = id + fun fieldName() = vo.fieldName() +} +``` + +### Entity 규칙 +- data class 사용 +- 모든 필드는 private +- `@field:Column`으로 JPA 어노테이션 적용 +- 비즈니스 로직은 Entity에 포함 +- Getter는 필드명과 동일한 메서드로 제공 (get 접두사 없음) +- VO는 `@Embedded`로 포함 + +## Value Object (VO) 작성 규칙 + +### 기본 구조 +```kotlin +@Embeddable +class ValueObject( + @field:Column(name = "field_name", nullable = false) + private val fieldName: Type, +) { + // 비즈니스 로직 및 검증 + fun businessLogic(): ValueObject { + validateSomething() + return ValueObject(newValue) + } + + private fun validateSomething() { + if (condition) { + throw DomainException() + } + } + + // Getter + fun fieldName() = fieldName +} +``` + +### VO 규칙 +- class 사용 (data class 아님) +- 불변성 유지 (val 사용) +- 변경이 필요한 경우 새 인스턴스 반환 +- 검증 로직 포함 +- `@Embeddable` 어노테이션 사용 + +## Repository 작성 규칙 + +### 3계층 구조 +1. **{Domain}Repository (인터페이스)**: 도메인 레이어의 포트 +2. **{Domain}RepositoryImpl**: 실제 구현체, QueryDSL 사용 +3. **{Domain}PersistenceRepository**: JpaRepository 확장 + +### 예시 +```kotlin +// 1. 인터페이스 +interface ItemRepository { + fun findAll(): List + fun save(item: Item): Item +} + +// 2. 구현체 +@Repository +class ItemRepositoryImpl( + private val jpaQueryFactory: JPAQueryFactory, + private val itemPersistenceRepository: ItemPersistenceRepository, +) : ItemRepository { + override fun findAll(): List { + return jpaQueryFactory + .selectFrom(item) + .fetch() + } +} + +// 3. JPA Repository +@Repository +interface ItemPersistenceRepository : JpaRepository +``` + +## Service 작성 규칙 + +### Command Service +- `@Transactional` 어노테이션 필수 +- 상태 변경 로직만 포함 +- 검증 로직은 private 메서드로 분리 + +### Query Service +- `@Transactional` 불필요 (읽기 전용) +- 조회 로직만 포함 +- 매핑 로직은 private 메서드로 분리 + +### 공통 규칙 +- 한 메서드는 한 가지 일만 +- 검증, 변환, 매핑 로직은 별도 private 메서드로 분리 +- depth 2 이상 중첩 금지 + +## Controller 작성 규칙 + +```kotlin +@RestController +@RequestMapping("/items") +class ItemCommandController( + private val itemCommandService: ItemCommandService, +) { + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun create(@RequestBody request: ItemCreateRequest): ItemResponse { + return itemCommandService.create(request) + } +} +``` + +### 규칙 +- REST 원칙 준수 +- `@ResponseStatus`로 상태 코드 명시 +- 비즈니스 로직은 Service에 위임 +- Command/Query 분리 + +## Exception 작성 규칙 + +### 도메인 예외 +- 위치: `domain/{domain}/domain/exception/` +- 도메인 규칙 위반 시 발생 + +### 애플리케이션 예외 +- 위치: `domain/{domain}/application/exception/` +- 애플리케이션 로직 실패 시 발생 + +### 기본 구조 +```kotlin +class SomeException : BusinessBaseException(ErrorMessage.SOME_ERROR) +``` + +## Mapper 작성 규칙 + +```kotlin +object ItemMapper { + fun toEntity(response: ItemResponse): Item { + return Item(...) + } + + fun toItemResponse(item: Item): ItemResponse { + return ItemResponse(...) + } +} +``` + +### 규칙 +- object로 선언 (싱글톤) +- Entity ↔ DTO 변환 담당 +- 명확한 네이밍: `toEntity`, `to{Dto}Response` + +## 코딩 스타일 + +### Kotlin 스타일 +- 들여쓰기: 4 spaces +- 파일 끝 EOF 필수 +- 사용하지 않는 import 제거 +- 명시적 타입 선언 (추론 가능한 경우 제외) +- **와일드카드 import(`import package.*`) 사용 금지 - 모든 클래스를 명시적으로 import** +- **제네릭 와일드카드(`List<*>`, `Map<*, *>`) 사용 금지 - 명시적 타입 파라미터 사용** + +### Null Safety (중요) +- **최대한 nullable(`?`)을 사용하지 않고 코드를 작성** +- 정말 필요한 경우에만 nullable 타입 사용 (예: 데이터베이스의 nullable 컬럼) +- `@Nonnull`, `@Nullable` 같은 Java 어노테이션 사용 금지 +- Kotlin의 null safety 기능을 활용 (`?.`, `?:`, `!!` 등) +- `!!` 연산자는 정말 확실한 경우에만 사용 +- null 대신 기본값, 빈 컬렉션, Optional 패턴 등을 활용 +- Repository 메서드는 가능한 한 Non-null 반환 (예외 발생 방식 선호) + +### 주석 +- 복잡한 비즈니스 로직에만 추가 +- 코드로 설명 가능한 경우 주석 생략 + +## 기술 스택 + +### 프레임워크 및 라이브러리 +- Spring Boot 3.5.5 +- Kotlin 1.9.25 +- JDK 21 +- Spring Security +- Spring Data JPA +- QueryDSL 5.1.0 +- MySQL +- Redis +- Feign Client (외부 API 통신) +- JWT (인증) + +### 빌드 도구 +- Gradle (Kotlin DSL) +- kapt (어노테이션 프로세싱) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/ChargeRequest.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/ChargeRequest.kt new file mode 100644 index 0000000..2a6261d --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/ChargeRequest.kt @@ -0,0 +1,19 @@ +package bssm.devcoop.occount.domain.transaction.application.dto.request + +import java.time.LocalDateTime + +data class ChargeRequest( + val userCode: String, + val transactionDate: LocalDateTime, + val beforePoint: Int, + val afterPoint: Int, + val managedEmail: String, + val chargeType: String, + val chargedPoint: Int, + val reason: String?, + val paymentId: String?, + val refundState: String?, + val refundDate: LocalDateTime?, + val refundRequesterId: String? +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/PayRequest.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/PayRequest.kt new file mode 100644 index 0000000..08ac29e --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/request/PayRequest.kt @@ -0,0 +1,20 @@ +package bssm.devcoop.occount.domain.transaction.application.dto.request + +import java.time.LocalDateTime + +data class PayRequest( + val userCode: String, + val transactionDate: LocalDateTime, + val beforePoint: Int, + val afterPoint: Int, + val managedEmail: String, + val payType: String, + val payedPoint: Int, + val itemScans: List +) + +data class ItemScanRequest( + val itemId: Int, + val scannedAt: LocalDateTime +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/response/TransactionResponse.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/response/TransactionResponse.kt new file mode 100644 index 0000000..dd88858 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/dto/response/TransactionResponse.kt @@ -0,0 +1,44 @@ +package bssm.devcoop.occount.domain.transaction.application.dto.response + +import java.time.LocalDateTime + +sealed class TransactionResponse { + data class ChargeResponse( + val transactionId: Long?, + val userCode: String, + val transactionDate: LocalDateTime, + val beforePoint: Int, + val afterPoint: Int, + val managedEmail: String, + val chargeType: String, + val chargedPoint: Int, + val reason: String?, + val paymentId: String?, + val refundState: String?, + val refundDate: LocalDateTime?, + val refundRequesterId: String? + ) : TransactionResponse() + + data class PayResponse( + val transactionId: Long?, + val userCode: String, + val transactionDate: LocalDateTime, + val beforePoint: Int, + val afterPoint: Int, + val managedEmail: String, + val payType: String, + val payedPoint: Int, + val itemScans: List + ) : TransactionResponse() +} + +data class ItemScanResponse( + val scanId: Int?, + val itemId: Int, + val scannedAt: LocalDateTime +) + +data class TransactionListResponse( + val transactions: List +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionApplicationErrorMessage.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionApplicationErrorMessage.kt new file mode 100644 index 0000000..bc71557 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionApplicationErrorMessage.kt @@ -0,0 +1,11 @@ +package bssm.devcoop.occount.domain.transaction.application.exception + +import org.springframework.http.HttpStatus + +enum class TransactionApplicationErrorMessage( + val httpStatus: HttpStatus, + val message: String +) { + TRANSACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 거래입니다.") +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionNotFoundException.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionNotFoundException.kt new file mode 100644 index 0000000..23b3b31 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/exception/TransactionNotFoundException.kt @@ -0,0 +1,8 @@ +package bssm.devcoop.occount.domain.transaction.application.exception + +import bssm.devcoop.occount.global.error.exception.BusinessBaseException + +class TransactionNotFoundException : BusinessBaseException( + TransactionApplicationErrorMessage.TRANSACTION_NOT_FOUND.httpStatus, + TransactionApplicationErrorMessage.TRANSACTION_NOT_FOUND.message +) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/mapper/TransactionMapper.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/mapper/TransactionMapper.kt new file mode 100644 index 0000000..38bd7ea --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/mapper/TransactionMapper.kt @@ -0,0 +1,89 @@ +package bssm.devcoop.occount.domain.transaction.application.mapper + +import bssm.devcoop.occount.domain.transaction.application.dto.request.ChargeRequest +import bssm.devcoop.occount.domain.transaction.application.dto.request.PayRequest +import bssm.devcoop.occount.domain.transaction.application.dto.response.ItemScanResponse +import bssm.devcoop.occount.domain.transaction.application.dto.response.TransactionResponse +import bssm.devcoop.occount.domain.transaction.domain.Charge +import bssm.devcoop.occount.domain.transaction.domain.ItemScan +import bssm.devcoop.occount.domain.transaction.domain.Pay +import bssm.devcoop.occount.domain.transaction.domain.Transaction +import bssm.devcoop.occount.domain.transaction.domain.exception.InvalidTransactionTypeException + +object TransactionMapper { + fun toEntity(request: ChargeRequest): Charge { + return Charge( + transactionId = null, + userCode = request.userCode, + transactionDate = request.transactionDate, + beforePoint = request.beforePoint, + afterPoint = request.afterPoint, + managedEmail = request.managedEmail, + chargeType = request.chargeType, + chargedPoint = request.chargedPoint, + reason = request.reason, + paymentId = request.paymentId, + refundState = request.refundState, + refundDate = request.refundDate, + refundRequesterId = request.refundRequesterId + ) + } + + fun toEntity(request: PayRequest): Pay { + return Pay( + transactionId = null, + userCode = request.userCode, + transactionDate = request.transactionDate, + beforePoint = request.beforePoint, + afterPoint = request.afterPoint, + managedEmail = request.managedEmail, + payType = request.payType, + payedPoint = request.payedPoint, + itemScans = request.itemScans.map { + ItemScan( + scanId = null, + itemId = it.itemId, + scannedAt = it.scannedAt + ) + } + ) + } + + fun toTransactionResponse(transaction: Transaction): TransactionResponse { + return when (transaction) { + is Charge -> TransactionResponse.ChargeResponse( + transactionId = transaction.transactionId(), + userCode = transaction.userCode(), + transactionDate = transaction.transactionDate(), + beforePoint = transaction.beforePoint(), + afterPoint = transaction.afterPoint(), + managedEmail = transaction.managedEmail(), + chargeType = transaction.chargeType(), + chargedPoint = transaction.chargedPoint(), + reason = transaction.reason(), + paymentId = transaction.paymentId(), + refundState = transaction.refundState(), + refundDate = transaction.refundDate(), + refundRequesterId = transaction.refundRequesterId() + ) + is Pay -> TransactionResponse.PayResponse( + transactionId = transaction.transactionId(), + userCode = transaction.userCode(), + transactionDate = transaction.transactionDate(), + beforePoint = transaction.beforePoint(), + afterPoint = transaction.afterPoint(), + managedEmail = transaction.managedEmail(), + payType = transaction.payType(), + payedPoint = transaction.payedPoint(), + itemScans = transaction.itemScans().map { + ItemScanResponse( + scanId = it.scanId(), + itemId = it.itemId(), + scannedAt = it.scannedAt() + ) + } + ) + else -> throw InvalidTransactionTypeException() + } + } +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionCommandService.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionCommandService.kt new file mode 100644 index 0000000..b079bc4 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionCommandService.kt @@ -0,0 +1,29 @@ +package bssm.devcoop.occount.domain.transaction.application.service + +import bssm.devcoop.occount.domain.transaction.application.dto.request.ChargeRequest +import bssm.devcoop.occount.domain.transaction.application.dto.request.PayRequest +import bssm.devcoop.occount.domain.transaction.application.dto.response.TransactionResponse +import bssm.devcoop.occount.domain.transaction.application.mapper.TransactionMapper +import bssm.devcoop.occount.domain.transaction.repository.TransactionRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class TransactionCommandService( + private val transactionRepository: TransactionRepository, +) { + @Transactional + fun createCharge(request: ChargeRequest): TransactionResponse { + val charge = TransactionMapper.toEntity(request) + val savedCharge = transactionRepository.save(charge) + return TransactionMapper.toTransactionResponse(savedCharge) + } + + @Transactional + fun createPay(request: PayRequest): TransactionResponse { + val pay = TransactionMapper.toEntity(request) + val savedPay = transactionRepository.save(pay) + return TransactionMapper.toTransactionResponse(savedPay) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionQueryService.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionQueryService.kt new file mode 100644 index 0000000..7b389ca --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/application/service/TransactionQueryService.kt @@ -0,0 +1,37 @@ +package bssm.devcoop.occount.domain.transaction.application.service + +import bssm.devcoop.occount.domain.transaction.application.dto.response.TransactionListResponse +import bssm.devcoop.occount.domain.transaction.application.mapper.TransactionMapper +import bssm.devcoop.occount.domain.transaction.domain.Transaction +import bssm.devcoop.occount.domain.transaction.domain.type.ChargeType +import bssm.devcoop.occount.domain.transaction.repository.TransactionRepository +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.LocalTime + +@Service +class TransactionQueryService( + private val transactionRepository: TransactionRepository, +) { + fun queryTransactionsByUserCode(userCode: String): TransactionListResponse { + val transactions = transactionRepository.findByUserCode(userCode) + return mapToTransactionListResponse(transactions) + } + + fun queryTodayChargeTotal(userCode: String): Int { + val todayStart = LocalDate.now().atStartOfDay() + val todayEnd = LocalDate.now().atTime(LocalTime.MAX) + val chargeTypes = listOf(ChargeType.TYPE2.value, ChargeType.TYPE3.value) + return transactionRepository.findTotalChargeByUserCodeAndDateBetween( + userCode, + todayStart, + todayEnd, + chargeTypes + ) + } + + private fun mapToTransactionListResponse(transactions: List): TransactionListResponse { + val transactionResponses = transactions.map { TransactionMapper.toTransactionResponse(it) } + return TransactionListResponse(transactionResponses) + } +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Charge.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Charge.kt new file mode 100644 index 0000000..3a53573 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Charge.kt @@ -0,0 +1,49 @@ +package bssm.devcoop.occount.domain.transaction.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "tbl_charge") +class Charge( + transactionId: Long?, + userCode: String, + transactionDate: LocalDateTime, + beforePoint: Int, + afterPoint: Int, + managedEmail: String, + + @field:Column(name = "charge_type", nullable = false) + private val chargeType: String, + + @field:Column(name = "charged_point", nullable = false) + private val chargedPoint: Int, + + @field:Column(name = "reason") + private val reason: String?, + + @field:Column(name = "payment_id") + private val paymentId: String?, + + @field:Column(name = "refund_state") + private val refundState: String?, + + @field:Column(name = "refund_date") + private val refundDate: LocalDateTime?, + + @field:Column(name = "refund_requester_id") + private val refundRequesterId: String? +) : Transaction(transactionId, userCode, transactionDate, beforePoint, afterPoint, managedEmail) { + protected constructor() : this(null, "", LocalDateTime.now(), 0, 0, "", "", 0, null, null, null, null, null) + + fun chargeType() = chargeType + fun chargedPoint() = chargedPoint + fun reason() = reason + fun paymentId() = paymentId + fun refundState() = refundState + fun refundDate() = refundDate + fun refundRequesterId() = refundRequesterId +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/ItemScan.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/ItemScan.kt new file mode 100644 index 0000000..3c92671 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/ItemScan.kt @@ -0,0 +1,31 @@ +package bssm.devcoop.occount.domain.transaction.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "tbl_item_scan") +class ItemScan( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "scan_id") + private val scanId: Int? = null, + + @field:Column(name = "item_id", nullable = false) + private val itemId: Int, + + @field:Column(name = "scanned_at", nullable = false) + private val scannedAt: LocalDateTime +) { + protected constructor() : this(null, 0, LocalDateTime.now()) + + fun scanId() = scanId + fun itemId() = itemId + fun scannedAt() = scannedAt +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Pay.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Pay.kt new file mode 100644 index 0000000..93a8d36 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Pay.kt @@ -0,0 +1,35 @@ +package bssm.devcoop.occount.domain.transaction.domain + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "tbl_pay") +class Pay( + transactionId: Long?, + userCode: String, + transactionDate: LocalDateTime, + beforePoint: Int, + afterPoint: Int, + managedEmail: String, + + @field:Column(name = "pay_type", nullable = false) + private val payType: String, + + @field:Column(name = "payed_point", nullable = false) + private val payedPoint: Int, + + @OneToMany(cascade = [CascadeType.ALL]) + private val itemScans: List +) : Transaction(transactionId, userCode, transactionDate, beforePoint, afterPoint, managedEmail) { + protected constructor() : this(null, "", LocalDateTime.now(), 0, 0, "", "", 0, emptyList()) + + fun payType() = payType + fun payedPoint() = payedPoint + fun itemScans() = itemScans +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Transaction.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Transaction.kt new file mode 100644 index 0000000..7bfb60b --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/Transaction.kt @@ -0,0 +1,44 @@ +package bssm.devcoop.occount.domain.transaction.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Inheritance +import jakarta.persistence.InheritanceType +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity +@Table(name = "tbl_transaction") +@Inheritance(strategy = InheritanceType.JOINED) +abstract class Transaction( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "transaction_id") + private val transactionId: Long? = null, + + @field:Column(name = "user_code", nullable = false) + private val userCode: String, + + @field:Column(name = "transaction_date", nullable = false) + private val transactionDate: LocalDateTime, + + @field:Column(name = "before_point", nullable = false) + private val beforePoint: Int, + + @field:Column(name = "after_point", nullable = false) + private val afterPoint: Int, + + @field:Column(name = "managed_email", nullable = false) + private val managedEmail: String +) { + fun transactionId() = transactionId + fun userCode() = userCode + fun transactionDate() = transactionDate + fun beforePoint() = beforePoint + fun afterPoint() = afterPoint + fun managedEmail() = managedEmail +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/InvalidTransactionTypeException.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/InvalidTransactionTypeException.kt new file mode 100644 index 0000000..591820a --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/InvalidTransactionTypeException.kt @@ -0,0 +1,9 @@ +package bssm.devcoop.occount.domain.transaction.domain.exception + +import bssm.devcoop.occount.global.error.exception.BusinessBaseException + +class InvalidTransactionTypeException : BusinessBaseException( + TransactionErrorMessage.INVALID_TRANSACTION_TYPE.httpStatus, + TransactionErrorMessage.INVALID_TRANSACTION_TYPE.message +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/TransactionErrorMessage.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/TransactionErrorMessage.kt new file mode 100644 index 0000000..9d314b2 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/exception/TransactionErrorMessage.kt @@ -0,0 +1,11 @@ +package bssm.devcoop.occount.domain.transaction.domain.exception + +import org.springframework.http.HttpStatus + +enum class TransactionErrorMessage( + val httpStatus: HttpStatus, + val message: String +) { + INVALID_TRANSACTION_TYPE(HttpStatus.BAD_REQUEST, "올바르지 않은 거래 타입입니다.") +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/type/ChargeType.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/type/ChargeType.kt new file mode 100644 index 0000000..e38ef33 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/domain/type/ChargeType.kt @@ -0,0 +1,11 @@ +package bssm.devcoop.occount.domain.transaction.domain.type + +enum class ChargeType(val value: String) { + TYPE2("2"), + TYPE3("3"); + + companion object { + fun fromValue(value: String): ChargeType? = entries.find { it.value == value } + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionCommandController.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionCommandController.kt new file mode 100644 index 0000000..639f42e --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionCommandController.kt @@ -0,0 +1,31 @@ +package bssm.devcoop.occount.domain.transaction.presentation + +import bssm.devcoop.occount.domain.transaction.application.dto.request.ChargeRequest +import bssm.devcoop.occount.domain.transaction.application.dto.request.PayRequest +import bssm.devcoop.occount.domain.transaction.application.dto.response.TransactionResponse +import bssm.devcoop.occount.domain.transaction.application.service.TransactionCommandService +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/transactions") +class TransactionCommandController( + private val transactionCommandService: TransactionCommandService, +) { + @PostMapping("/charge") + @ResponseStatus(HttpStatus.CREATED) + fun createCharge(@RequestBody request: ChargeRequest): TransactionResponse { + return transactionCommandService.createCharge(request) + } + + @PostMapping("/pay") + @ResponseStatus(HttpStatus.CREATED) + fun createPay(@RequestBody request: PayRequest): TransactionResponse { + return transactionCommandService.createPay(request) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionQueryController.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionQueryController.kt new file mode 100644 index 0000000..2156e63 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/presentation/TransactionQueryController.kt @@ -0,0 +1,29 @@ +package bssm.devcoop.occount.domain.transaction.presentation + +import bssm.devcoop.occount.domain.transaction.application.dto.response.TransactionListResponse +import bssm.devcoop.occount.domain.transaction.application.service.TransactionQueryService +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/transactions") +class TransactionQueryController( + private val transactionQueryService: TransactionQueryService, +) { + @GetMapping("/{userCode}") + @ResponseStatus(HttpStatus.OK) + fun getTransactionsByUser(@PathVariable userCode: String): TransactionListResponse { + return transactionQueryService.queryTransactionsByUserCode(userCode) + } + + @GetMapping("/{userCode}/today-charge-total") + @ResponseStatus(HttpStatus.OK) + fun getTodayChargeTotal(@PathVariable userCode: String): Int { + return transactionQueryService.queryTodayChargeTotal(userCode) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionPersistenceRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionPersistenceRepository.kt new file mode 100644 index 0000000..0839364 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionPersistenceRepository.kt @@ -0,0 +1,11 @@ +package bssm.devcoop.occount.domain.transaction.repository + +import bssm.devcoop.occount.domain.transaction.domain.Transaction +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface TransactionPersistenceRepository : JpaRepository { + fun findByUserCode(userCode: String): List +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepository.kt new file mode 100644 index 0000000..6e2c128 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepository.kt @@ -0,0 +1,15 @@ +package bssm.devcoop.occount.domain.transaction.repository + +import bssm.devcoop.occount.domain.transaction.domain.Transaction +import java.time.LocalDateTime + +interface TransactionRepository { + fun findByUserCode(userCode: String): List + fun save(transaction: Transaction): Transaction + fun findTotalChargeByUserCodeAndDateBetween( + userCode: String, + startDate: LocalDateTime, + endDate: LocalDateTime, + chargeTypes: List + ): Int +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepositoryImpl.kt b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepositoryImpl.kt new file mode 100644 index 0000000..6fd776b --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/transaction/repository/TransactionRepositoryImpl.kt @@ -0,0 +1,39 @@ +package bssm.devcoop.occount.domain.transaction.repository + +import bssm.devcoop.occount.domain.transaction.domain.QCharge.charge +import bssm.devcoop.occount.domain.transaction.domain.Transaction +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class TransactionRepositoryImpl( + private val jpaQueryFactory: JPAQueryFactory, + private val transactionPersistenceRepository: TransactionPersistenceRepository, +) : TransactionRepository { + override fun findByUserCode(userCode: String): List { + return transactionPersistenceRepository.findByUserCode(userCode) + } + + override fun save(transaction: Transaction): Transaction { + return transactionPersistenceRepository.save(transaction) + } + + override fun findTotalChargeByUserCodeAndDateBetween( + userCode: String, + startDate: LocalDateTime, + endDate: LocalDateTime, + chargeTypes: List + ): Int { + return jpaQueryFactory + .select(charge.chargedPoint.sum()) + .from(charge) + .where( + charge.userCode.eq(userCode), + charge.transactionDate.between(startDate, endDate), + charge.refundState.isNull, + charge.chargeType.`in`(chargeTypes) + ) + .fetchOne() ?: 0 + } +} diff --git a/src/main/kotlin/bssm/devcoop/occount/global/error/ErrorMessage.kt b/src/main/kotlin/bssm/devcoop/occount/global/error/ErrorMessage.kt index a39b972..2f42cd8 100644 --- a/src/main/kotlin/bssm/devcoop/occount/global/error/ErrorMessage.kt +++ b/src/main/kotlin/bssm/devcoop/occount/global/error/ErrorMessage.kt @@ -13,6 +13,7 @@ enum class ErrorMessage( USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 유저입니다."), INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + // JWT EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다"), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "올바르지 않은 토큰입니다.") diff --git a/src/main/kotlin/bssm/devcoop/occount/global/error/exception/BusinessBaseException.kt b/src/main/kotlin/bssm/devcoop/occount/global/error/exception/BusinessBaseException.kt index bc2d234..60e02a1 100644 --- a/src/main/kotlin/bssm/devcoop/occount/global/error/exception/BusinessBaseException.kt +++ b/src/main/kotlin/bssm/devcoop/occount/global/error/exception/BusinessBaseException.kt @@ -1,7 +1,11 @@ package bssm.devcoop.occount.global.error.exception import bssm.devcoop.occount.global.error.ErrorMessage +import org.springframework.http.HttpStatus abstract class BusinessBaseException( - errorMessage: ErrorMessage -): RuntimeException(errorMessage.message) \ No newline at end of file + val httpStatus: HttpStatus, + message: String +) : RuntimeException(message) { + constructor(errorMessage: ErrorMessage) : this(errorMessage.httpStatus, errorMessage.message) +} diff --git a/src/main/kotlin/bssm/devcoop/occount/global/error/exception/GlobalExceptionHandler.kt b/src/main/kotlin/bssm/devcoop/occount/global/error/exception/GlobalExceptionHandler.kt index 5a70acd..ef40693 100644 --- a/src/main/kotlin/bssm/devcoop/occount/global/error/exception/GlobalExceptionHandler.kt +++ b/src/main/kotlin/bssm/devcoop/occount/global/error/exception/GlobalExceptionHandler.kt @@ -9,6 +9,12 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class GlobalExceptionHandler { + @ExceptionHandler(BusinessBaseException::class) + fun handleBusinessException(e: BusinessBaseException): ResponseEntity> { + val error = mapOf("message" to (e.message ?: "Business logic error")) + return ResponseEntity.status(e.httpStatus).body(error) + } + @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidationException(e: MethodArgumentNotValidException): ResponseEntity> { val errors = e.bindingResult.allErrors.associate {