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/ari_pick_api_spec.md b/ari_pick_api_spec.md new file mode 100644 index 0000000..7013ed1 --- /dev/null +++ b/ari_pick_api_spec.md @@ -0,0 +1,168 @@ +# AriPick 제안 시스템 API 명세서 + +아리픽(AriPick)에서 학생들이 상품을 제안하고, 목록을 조회하고, 승인/검토 상태 관리 및 좋아요 기능을 수행하기 위한 REST API 명세서입니다. + +--- + +# 1. 제안(Proposals) API + +## 1.1 제안 생성 +### `POST /api/proposals` + +### Request Body +```json +{ + "title": "상품명", + "reason": "상품을 제안하는 이유", + "writerId": 1 +} +``` + +### Response +```json +{ + "id": 12, + "title": "상품명", + "reason": "제안 이유", + "writerId": 1, + "createdAt": "2025-12-31T10:20:30", + "status": "PENDING" +} +``` + +--- + +## 1.2 제안 목록 조회 +### `GET /api/proposals` + +### Query Parameters +- `status` (optional: `PENDING`, `APPROVED`, `REJECTED`) +- `page` +- `size` + +### Response +```json +{ + "content": [ + { + "id": 1, + "title": "상품명", + "reason": "이유 설명", + "createdAt": "2025-12-31", + "status": "APPROVED", + "likes": 999 + } + ], + "page": 0, + "size": 10, + "totalElements": 42 +} +``` + +--- + +## 1.3 제안 상세 조회 +### `GET /api/proposals/{proposalId}` + +### Response +```json +{ + "id": 1, + "title": "상품명", + "reason": "이유 설명", + "writerId": 1, + "createdAt": "2025-12-31T00:00:00", + "status": "APPROVED", + "likes": 999 +} +``` + +--- + +## 1.4 제안 상태 변경 (관리자) +### `PATCH /api/proposals/{proposalId}/status` + +### Request Body +```json +{ + "status": "APPROVED" +} +``` + +### Response +```json +{ + "id": 1, + "status": "APPROVED" +} +``` + +--- + +## 1.5 제안 삭제 +### `DELETE /api/proposals/{proposalId}` + +### Response +204 No Content + +--- + +# 2. 좋아요(Like) API + +## 2.1 좋아요 등록 +### `POST /api/proposals/{proposalId}/like` + +### Response +```json +{ + "proposalId": 1, + "liked": true, + "likeCount": 1000 +} +``` + +--- + +## 2.2 좋아요 취소 +### `DELETE /api/proposals/{proposalId}/like` + +### Response +```json +{ + "proposalId": 1, + "liked": false, + "likeCount": 999 +} +``` + +--- + +# 3. 통계(옵션) + +## 3.1 제안 통계 조회 +### `GET /api/proposals/stats` + +### Response +```json +{ + "totalProposals": 120, + "approved": 45, + "pending": 65, + "rejected": 10 +} +``` + +--- + +# 4. API 요약표 + +| 기능 | 메서드 | URL | +|------|--------|-------------| +| 제안 등록 | POST | `/api/proposals` | +| 제안 목록 조회 | GET | `/api/proposals` | +| 제안 상세 조회 | GET | `/api/proposals/{id}` | +| 제안 상태 변경 | PATCH | `/api/proposals/{id}/status` | +| 제안 삭제 | DELETE | `/api/proposals/{id}` | +| 좋아요 등록 | POST | `/api/proposals/{id}/like` | +| 좋아요 취소 | DELETE | `/api/proposals/{id}/like` | +| 통계 조회 | GET | `/api/proposals/stats` | 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/proposal/application/dto/request/ProposalCreateRequest.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/request/ProposalCreateRequest.kt new file mode 100644 index 0000000..5358edd --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/request/ProposalCreateRequest.kt @@ -0,0 +1,15 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.request + +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size + +data class ProposalCreateRequest( + @field:Size(min = 1, max = 100, message = "제목은 1자 이상 100자 이하여야 합니다") + val title: String, + + @field:Size(min = 1, max = 1000, message = "제안 이유는 1자 이상 1000자 이하여야 합니다") + val reason: String, + + @field:Positive(message = "작성자 ID는 양수여야 합니다") + val writerId: Long +) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/request/ProposalStatusUpdateRequest.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/request/ProposalStatusUpdateRequest.kt new file mode 100644 index 0000000..1d63796 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/request/ProposalStatusUpdateRequest.kt @@ -0,0 +1,7 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.request + +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus + +data class ProposalStatusUpdateRequest( + val status: ProposalStatus +) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalLikeResponse.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalLikeResponse.kt new file mode 100644 index 0000000..999a408 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalLikeResponse.kt @@ -0,0 +1,7 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.response + +data class ProposalLikeResponse( + val proposalId: Long, + val liked: Boolean, + val likeCount: Long +) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalListResponse.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalListResponse.kt new file mode 100644 index 0000000..46fc664 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalListResponse.kt @@ -0,0 +1,9 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.response + +data class ProposalListResponse( + val content: List, + val page: Int, + val size: Int, + val totalElements: Long +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalResponse.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalResponse.kt new file mode 100644 index 0000000..9d3a61e --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalResponse.kt @@ -0,0 +1,14 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.response + +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import java.time.LocalDateTime + +data class ProposalResponse( + val id: Long, + val title: String, + val reason: String, + val writerId: Long, + val createdAt: LocalDateTime, + val status: ProposalStatus, + val likes: Long +) diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalStatsResponse.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalStatsResponse.kt new file mode 100644 index 0000000..448aeb2 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/dto/response/ProposalStatsResponse.kt @@ -0,0 +1,9 @@ +package bssm.devcoop.occount.domain.proposal.application.dto.response + +data class ProposalStatsResponse( + val totalProposals: Long, + val approved: Long, + val pending: Long, + val rejected: Long +) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalAlreadyLikedException.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalAlreadyLikedException.kt new file mode 100644 index 0000000..7bf0dca --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalAlreadyLikedException.kt @@ -0,0 +1,7 @@ +package bssm.devcoop.occount.domain.proposal.application.exception + +import bssm.devcoop.occount.global.error.ErrorMessage +import bssm.devcoop.occount.global.error.exception.BusinessBaseException + +class ProposalAlreadyLikedException : BusinessBaseException(ErrorMessage.PROPOSAL_ALREADY_LIKED) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalLikeNotFoundException.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalLikeNotFoundException.kt new file mode 100644 index 0000000..7ff59f6 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalLikeNotFoundException.kt @@ -0,0 +1,7 @@ +package bssm.devcoop.occount.domain.proposal.application.exception + +import bssm.devcoop.occount.global.error.ErrorMessage +import bssm.devcoop.occount.global.error.exception.BusinessBaseException + +class ProposalLikeNotFoundException : BusinessBaseException(ErrorMessage.PROPOSAL_LIKE_NOT_FOUND) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalNotFoundException.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalNotFoundException.kt new file mode 100644 index 0000000..dcad24b --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/exception/ProposalNotFoundException.kt @@ -0,0 +1,7 @@ +package bssm.devcoop.occount.domain.proposal.application.exception + +import bssm.devcoop.occount.global.error.ErrorMessage +import bssm.devcoop.occount.global.error.exception.BusinessBaseException + +class ProposalNotFoundException : BusinessBaseException(ErrorMessage.PROPOSAL_NOT_FOUND) + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/mapper/ProposalMapper.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/mapper/ProposalMapper.kt new file mode 100644 index 0000000..d41073c --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/mapper/ProposalMapper.kt @@ -0,0 +1,31 @@ +package bssm.devcoop.occount.domain.proposal.application.mapper + +import bssm.devcoop.occount.domain.proposal.application.dto.request.ProposalCreateRequest +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalResponse +import bssm.devcoop.occount.domain.proposal.domain.Proposal +import bssm.devcoop.occount.domain.proposal.domain.vo.ProposalInfo + +object ProposalMapper { + fun toEntity(request: ProposalCreateRequest): Proposal { + return Proposal( + proposalInfo = ProposalInfo( + title = request.title, + reason = request.reason + ), + writerId = request.writerId + ) + } + + fun toProposalResponse(proposal: Proposal): ProposalResponse { + return ProposalResponse( + id = proposal.proposalId(), + title = proposal.title(), + reason = proposal.reason(), + writerId = proposal.writerId(), + createdAt = proposal.createdAt(), + status = proposal.status(), + likes = proposal.likeCount() + ) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalCommandService.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalCommandService.kt new file mode 100644 index 0000000..4cc8328 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalCommandService.kt @@ -0,0 +1,100 @@ +package bssm.devcoop.occount.domain.proposal.application.service + +import bssm.devcoop.occount.domain.proposal.application.dto.request.ProposalCreateRequest +import bssm.devcoop.occount.domain.proposal.application.dto.request.ProposalStatusUpdateRequest +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalLikeResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalResponse +import bssm.devcoop.occount.domain.proposal.application.exception.ProposalAlreadyLikedException +import bssm.devcoop.occount.domain.proposal.application.exception.ProposalLikeNotFoundException +import bssm.devcoop.occount.domain.proposal.application.exception.ProposalNotFoundException +import bssm.devcoop.occount.domain.proposal.application.mapper.ProposalMapper +import bssm.devcoop.occount.domain.proposal.domain.ProposalLike +import bssm.devcoop.occount.domain.proposal.repository.ProposalLikeRepository +import bssm.devcoop.occount.domain.proposal.repository.ProposalRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ProposalCommandService( + private val proposalRepository: ProposalRepository, + private val proposalLikeRepository: ProposalLikeRepository, +) { + + @Transactional + fun createProposal(request: ProposalCreateRequest): ProposalResponse { + val proposal = ProposalMapper.toEntity(request) + val savedProposal = proposalRepository.save(proposal) + return ProposalMapper.toProposalResponse(savedProposal) + } + + @Transactional + fun updateProposalStatus(proposalId: Long, request: ProposalStatusUpdateRequest): ProposalResponse { + val proposal = findProposalById(proposalId) + proposal.updateStatus(request.status) + val savedProposal = proposalRepository.save(proposal) + return ProposalMapper.toProposalResponse(savedProposal) + } + + @Transactional + fun deleteProposal(proposalId: Long) { + val proposal = findProposalById(proposalId) + proposal.delete() + proposalRepository.save(proposal) + } + + @Transactional + fun likeProposal(proposalId: Long, userId: Long): ProposalLikeResponse { + val proposal = findProposalById(proposalId) + + validateNotAlreadyLiked(proposalId, userId) + + val proposalLike = ProposalLike( + proposalId = proposalId, + userId = userId + ) + + proposalLikeRepository.save(proposalLike) + proposal.incrementLikeCount() + proposalRepository.save(proposal) + + return ProposalLikeResponse( + proposalId = proposalId, + liked = true, + likeCount = proposal.likeCount() + ) + } + + @Transactional + fun unlikeProposal(proposalId: Long, userId: Long): ProposalLikeResponse { + val proposal = findProposalById(proposalId) + + validateAlreadyLiked(proposalId, userId) + + proposalLikeRepository.deleteByProposalIdAndUserId(proposalId, userId) + proposal.decrementLikeCount() + proposalRepository.save(proposal) + + return ProposalLikeResponse( + proposalId = proposalId, + liked = false, + likeCount = proposal.likeCount() + ) + } + + private fun findProposalById(proposalId: Long) = + proposalRepository.findById(proposalId) + ?: throw ProposalNotFoundException() + + private fun validateNotAlreadyLiked(proposalId: Long, userId: Long) { + if (proposalLikeRepository.existsByProposalIdAndUserId(proposalId, userId)) { + throw ProposalAlreadyLikedException() + } + } + + private fun validateAlreadyLiked(proposalId: Long, userId: Long) { + if (!proposalLikeRepository.existsByProposalIdAndUserId(proposalId, userId)) { + throw ProposalLikeNotFoundException() + } + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalQueryService.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalQueryService.kt new file mode 100644 index 0000000..d0309ad --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/application/service/ProposalQueryService.kt @@ -0,0 +1,54 @@ +package bssm.devcoop.occount.domain.proposal.application.service + +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalListResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalStatsResponse +import bssm.devcoop.occount.domain.proposal.application.exception.ProposalNotFoundException +import bssm.devcoop.occount.domain.proposal.application.mapper.ProposalMapper +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import bssm.devcoop.occount.domain.proposal.repository.ProposalRepository +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Service + +@Service +class ProposalQueryService( + private val proposalRepository: ProposalRepository, +) { + + fun queryProposalById(proposalId: Long): ProposalResponse { + val proposal = proposalRepository.findById(proposalId) + ?: throw ProposalNotFoundException() + + return ProposalMapper.toProposalResponse(proposal) + } + + fun queryProposals(status: ProposalStatus?, page: Int, size: Int): ProposalListResponse { + val pageable = PageRequest.of(page, size) + val proposalPage = proposalRepository.findAll(status, pageable) + + val proposalResponses = proposalPage.content + .map(ProposalMapper::toProposalResponse) + + return ProposalListResponse( + content = proposalResponses, + page = proposalPage.number, + size = proposalPage.size, + totalElements = proposalPage.totalElements + ) + } + + fun queryProposalStats(): ProposalStatsResponse { + val totalProposals = proposalRepository.countAll() + val approvedCount = proposalRepository.countByStatus(ProposalStatus.APPROVED) + val pendingCount = proposalRepository.countByStatus(ProposalStatus.PENDING) + val rejectedCount = proposalRepository.countByStatus(ProposalStatus.REJECTED) + + return ProposalStatsResponse( + totalProposals = totalProposals, + approved = approvedCount, + pending = pendingCount, + rejected = rejectedCount + ) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/Proposal.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/Proposal.kt new file mode 100644 index 0000000..f1539cf --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/Proposal.kt @@ -0,0 +1,74 @@ +package bssm.devcoop.occount.domain.proposal.domain + +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import bssm.devcoop.occount.domain.proposal.domain.vo.ProposalInfo +import jakarta.persistence.Column +import jakarta.persistence.Embedded +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.persistence.Version +import java.time.LocalDateTime + +@Entity +@Table(name = "proposal") +class Proposal( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "proposal_id") + private val proposalId: Long = 0L, + + @Embedded + private val proposalInfo: ProposalInfo, + + @field:Column(name = "writer_id", nullable = false) + private val writerId: Long, + + @Enumerated(EnumType.STRING) + @field:Column(name = "status", nullable = false) + private var status: ProposalStatus = ProposalStatus.PENDING, + + @field:Column(name = "created_at", nullable = false) + private val createdAt: LocalDateTime = LocalDateTime.now(), + + @field:Column(name = "like_count", nullable = false) + private var likeCount: Long = 0L, + + @field:Column(name = "is_deleted", nullable = false) + private var isDeleted: Boolean = false, + + @Version + @field:Column(name = "version") + private var version: Long = 0L, +) { + fun updateStatus(newStatus: ProposalStatus) { + this.status = newStatus + } + + fun incrementLikeCount() { + this.likeCount++ + } + + fun decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount-- + } + } + + fun delete() { + this.isDeleted = true + } + + fun proposalId() = proposalId + fun title() = proposalInfo.title() + fun reason() = proposalInfo.reason() + fun writerId() = writerId + fun status() = status + fun createdAt() = createdAt + fun likeCount() = likeCount + fun isDeleted() = isDeleted +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/ProposalLike.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/ProposalLike.kt new file mode 100644 index 0000000..fe61f9b --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/ProposalLike.kt @@ -0,0 +1,36 @@ +package bssm.devcoop.occount.domain.proposal.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 jakarta.persistence.UniqueConstraint + +@Entity +@Table( + name = "proposal_like", + uniqueConstraints = [ + UniqueConstraint( + name = "uk_proposal_user", + columnNames = ["proposal_id", "user_id"] + ) + ] +) +class ProposalLike( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @field:Column(name = "like_id") + private val likeId: Long = 0L, + + @field:Column(name = "proposal_id", nullable = false) + private val proposalId: Long, + + @field:Column(name = "user_id", nullable = false) + private val userId: Long, +) { + fun likeId() = likeId + fun proposalId() = proposalId + fun userId() = userId +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/type/ProposalStatus.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/type/ProposalStatus.kt new file mode 100644 index 0000000..c16a0a8 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/type/ProposalStatus.kt @@ -0,0 +1,8 @@ +package bssm.devcoop.occount.domain.proposal.domain.type + +enum class ProposalStatus { + PENDING, + APPROVED, + REJECTED +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/vo/ProposalInfo.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/vo/ProposalInfo.kt new file mode 100644 index 0000000..252f6eb --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/domain/vo/ProposalInfo.kt @@ -0,0 +1,17 @@ +package bssm.devcoop.occount.domain.proposal.domain.vo + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable + +@Embeddable +class ProposalInfo( + @field:Column(name = "title", nullable = false) + private val title: String, + + @field:Column(name = "reason", nullable = false, columnDefinition = "TEXT") + private val reason: String, +) { + fun title() = title + fun reason() = reason +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalCommandController.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalCommandController.kt new file mode 100644 index 0000000..f27da51 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalCommandController.kt @@ -0,0 +1,64 @@ +package bssm.devcoop.occount.domain.proposal.presentation + +import bssm.devcoop.occount.domain.proposal.application.dto.request.ProposalCreateRequest +import bssm.devcoop.occount.domain.proposal.application.dto.request.ProposalStatusUpdateRequest +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalLikeResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalResponse +import bssm.devcoop.occount.domain.proposal.application.service.ProposalCommandService +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +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.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/proposals") +class ProposalCommandController( + private val proposalCommandService: ProposalCommandService, +) { + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + fun createProposal(@Valid @RequestBody request: ProposalCreateRequest): ProposalResponse { + return proposalCommandService.createProposal(request) + } + + @PatchMapping("/{proposalId}/status") + @ResponseStatus(HttpStatus.OK) + fun updateProposalStatus( + @PathVariable proposalId: Long, + @Valid @RequestBody request: ProposalStatusUpdateRequest + ): ProposalResponse { + return proposalCommandService.updateProposalStatus(proposalId, request) + } + + @DeleteMapping("/{proposalId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + fun deleteProposal(@PathVariable proposalId: Long) { + proposalCommandService.deleteProposal(proposalId) + } + + @PostMapping("/{proposalId}/like") + @ResponseStatus(HttpStatus.OK) + fun likeProposal( + @PathVariable proposalId: Long, + @RequestParam userId: Long + ): ProposalLikeResponse { + return proposalCommandService.likeProposal(proposalId, userId) + } + + @DeleteMapping("/{proposalId}/like") + @ResponseStatus(HttpStatus.OK) + fun unlikeProposal( + @PathVariable proposalId: Long, + @RequestParam userId: Long + ): ProposalLikeResponse { + return proposalCommandService.unlikeProposal(proposalId, userId) + } +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalQueryController.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalQueryController.kt new file mode 100644 index 0000000..8dda194 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/presentation/ProposalQueryController.kt @@ -0,0 +1,47 @@ +package bssm.devcoop.occount.domain.proposal.presentation + +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalListResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalResponse +import bssm.devcoop.occount.domain.proposal.application.dto.response.ProposalStatsResponse +import bssm.devcoop.occount.domain.proposal.application.service.ProposalQueryService +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import org.springframework.http.HttpStatus +import org.springframework.validation.annotation.Validated +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.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@Validated +@RestController +@RequestMapping("/api/proposals") +class ProposalQueryController( + private val proposalQueryService: ProposalQueryService, +) { + + @GetMapping("/{proposalId}") + @ResponseStatus(HttpStatus.OK) + fun getProposalById(@PathVariable proposalId: Long): ProposalResponse { + return proposalQueryService.queryProposalById(proposalId) + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + fun getProposals( + @RequestParam(required = false) status: ProposalStatus?, + @RequestParam(defaultValue = "0") @Min(0, message = "페이지 번호는 0 이상이어야 합니다") page: Int, + @RequestParam(defaultValue = "10") @Min(1, message = "페이지 크기는 1 이상이어야 합니다") @Max(100, message = "페이지 크기는 100 이하여야 합니다") size: Int + ): ProposalListResponse { + return proposalQueryService.queryProposals(status, page, size) + } + + @GetMapping("/stats") + @ResponseStatus(HttpStatus.OK) + fun getProposalStats(): ProposalStatsResponse { + return proposalQueryService.queryProposalStats() + } +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikePersistenceRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikePersistenceRepository.kt new file mode 100644 index 0000000..43cd29c --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikePersistenceRepository.kt @@ -0,0 +1,15 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.ProposalLike +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ProposalLikePersistenceRepository : JpaRepository { + fun findByProposalIdAndUserId(proposalId: Long, userId: Long): ProposalLike? + + fun existsByProposalIdAndUserId(proposalId: Long, userId: Long): Boolean + + fun deleteByProposalIdAndUserId(proposalId: Long, userId: Long) +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepository.kt new file mode 100644 index 0000000..cc63db6 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepository.kt @@ -0,0 +1,14 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.ProposalLike + +interface ProposalLikeRepository { + fun save(proposalLike: ProposalLike): ProposalLike + + fun findByProposalIdAndUserId(proposalId: Long, userId: Long): ProposalLike? + + fun existsByProposalIdAndUserId(proposalId: Long, userId: Long): Boolean + + fun deleteByProposalIdAndUserId(proposalId: Long, userId: Long) +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepositoryImpl.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepositoryImpl.kt new file mode 100644 index 0000000..0c12716 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalLikeRepositoryImpl.kt @@ -0,0 +1,27 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.ProposalLike +import org.springframework.stereotype.Repository + +@Repository +class ProposalLikeRepositoryImpl( + private val proposalLikePersistenceRepository: ProposalLikePersistenceRepository, +) : ProposalLikeRepository { + + override fun save(proposalLike: ProposalLike): ProposalLike { + return proposalLikePersistenceRepository.save(proposalLike) + } + + override fun findByProposalIdAndUserId(proposalId: Long, userId: Long): ProposalLike? { + return proposalLikePersistenceRepository.findByProposalIdAndUserId(proposalId, userId) + } + + override fun existsByProposalIdAndUserId(proposalId: Long, userId: Long): Boolean { + return proposalLikePersistenceRepository.existsByProposalIdAndUserId(proposalId, userId) + } + + override fun deleteByProposalIdAndUserId(proposalId: Long, userId: Long) { + proposalLikePersistenceRepository.deleteByProposalIdAndUserId(proposalId, userId) + } +} + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalPersistenceRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalPersistenceRepository.kt new file mode 100644 index 0000000..d05b6b6 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalPersistenceRepository.kt @@ -0,0 +1,9 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.Proposal +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface ProposalPersistenceRepository : JpaRepository + diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepository.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepository.kt new file mode 100644 index 0000000..31ba89a --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepository.kt @@ -0,0 +1,18 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.Proposal +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable + +interface ProposalRepository { + fun save(proposal: Proposal): Proposal + + fun findById(id: Long): Proposal? + + fun findAll(status: ProposalStatus?, pageable: Pageable): Page + + fun countByStatus(status: ProposalStatus): Long + + fun countAll(): Long +} diff --git a/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepositoryImpl.kt b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepositoryImpl.kt new file mode 100644 index 0000000..6c03da6 --- /dev/null +++ b/src/main/kotlin/bssm/devcoop/occount/domain/proposal/repository/ProposalRepositoryImpl.kt @@ -0,0 +1,69 @@ +package bssm.devcoop.occount.domain.proposal.repository + +import bssm.devcoop.occount.domain.proposal.domain.Proposal +import bssm.devcoop.occount.domain.proposal.domain.QProposal.proposal +import bssm.devcoop.occount.domain.proposal.domain.type.ProposalStatus +import com.querydsl.jpa.impl.JPAQueryFactory +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Repository + +@Repository +class ProposalRepositoryImpl( + private val jpaQueryFactory: JPAQueryFactory, + private val proposalPersistenceRepository: ProposalPersistenceRepository, +) : ProposalRepository { + + override fun save(proposal: Proposal): Proposal { + return proposalPersistenceRepository.save(proposal) + } + + override fun findById(id: Long): Proposal? { + return proposalPersistenceRepository.findById(id).orElse(null) + } + + override fun findAll(status: ProposalStatus?, pageable: Pageable): Page { + val query = jpaQueryFactory + .selectFrom(proposal) + .where( + proposal.isDeleted.eq(false), + status?.let { proposal.status.eq(it) } + ) + .orderBy(proposal.createdAt.desc()) + .offset(pageable.offset) + .limit(pageable.pageSize.toLong()) + + val content = query.fetch() + + val total = jpaQueryFactory + .select(proposal.count()) + .from(proposal) + .where( + proposal.isDeleted.eq(false), + status?.let { proposal.status.eq(it) } + ) + .fetchOne() ?: 0L + + return PageImpl(content, pageable, total) + } + + override fun countByStatus(status: ProposalStatus): Long { + return jpaQueryFactory + .select(proposal.count()) + .from(proposal) + .where( + proposal.status.eq(status), + proposal.isDeleted.eq(false) + ) + .fetchOne() ?: 0L + } + + override fun countAll(): Long { + return jpaQueryFactory + .select(proposal.count()) + .from(proposal) + .where(proposal.isDeleted.eq(false)) + .fetchOne() ?: 0L + } +} 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..7c91622 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,11 @@ enum class ErrorMessage( USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 유저입니다."), INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + // Proposal + PROPOSAL_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 제안입니다."), + PROPOSAL_ALREADY_LIKED(HttpStatus.CONFLICT, "이미 좋아요를 누른 제안입니다."), + PROPOSAL_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "좋아요를 누르지 않은 제안입니다."), + // JWT EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다"), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "올바르지 않은 토큰입니다.")