From b986114f8d7f1e750d3b0cad478b8b24c01c5961 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:06:31 +0900 Subject: [PATCH 01/14] =?UTF-8?q?docs:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EB=AA=85=EC=84=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/battle-proposal-api.md | 137 ++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/api-specs/battle-proposal-api.md diff --git a/docs/api-specs/battle-proposal-api.md b/docs/api-specs/battle-proposal-api.md new file mode 100644 index 0000000..ad591f2 --- /dev/null +++ b/docs/api-specs/battle-proposal-api.md @@ -0,0 +1,137 @@ +# 배틀 주제 제안(Battle Proposal) API 명세 + +기준 코드: `src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java`, +`src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java` + +--- + +## 1. 사용자 API + +### 1.1 배틀 주제 제안 등록 +- `POST /api/v1/battles/proposals` +- 설명: 유저가 배틀 주제를 제안합니다. 제안 시 30크레딧이 차감됩니다. +- 요청 본문(`BattleProposalRequest`) 주요 필드: + - `category` (필수) — 카테고리 (철학, 문학, 예술, 과학, 사회, 역사) + - `topic` (필수) — 논쟁 주제 (최대 100자) + - `positionA` (필수) — A 입장 + - `positionB` (필수) — B 입장 + - `description` (선택) — 부가 설명 (최대 200자) + +#### 성공 응답 `201 Created` +```json +{ + "statusCode": 201, + "data": { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "PENDING", + "createdAt": "2026-04-11T10:00:00" + }, + "error": null +} +``` + +--- + +## 2. 관리자 API + +기준 컨트롤러: `AdminBattleProposalController` + +### 2.1 배틀 주제 제안 목록 조회 +- `GET /api/v1/admin/battles/proposals` +- 쿼리 파라미터: + - `page` (기본값: `1`) + - `size` (기본값: `10`) + - `status` (선택, 허용: `PENDING`, `ACCEPTED`, `REJECTED`) + +#### 성공 응답 `200 OK` +```json +{ + "statusCode": 200, + "data": { + "content": [ + { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "PENDING", + "createdAt": "2026-04-11T10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "page": 1, + "size": 10 + }, + "error": null +} +``` + +### 2.2 배틀 주제 채택/미채택 처리 +- `PATCH /api/v1/admin/battles/proposals/{proposalId}` +- 설명: 제안된 주제를 채택하거나 거절합니다. 채택 시 제안자에게 100크레딧이 지급됩니다. +- 요청 본문(`BattleProposalReviewRequest`) 주요 필드: + - `action` (필수) — `ACCEPT` 또는 `REJECT` + +#### 성공 응답 `200 OK` +```json +{ + "statusCode": 200, + "data": { + "id": 1, + "userId": 100, + "nickname": "유저닉네임", + "category": "철학", + "topic": "논쟁이 될만한 주제", + "positionA": "첫 번째 입장", + "positionB": "두 번째 입장", + "description": "부가 설명", + "status": "ACCEPTED", + "createdAt": "2026-04-11T10:00:00" + }, + "error": null +} +``` + +--- + +## 3. 상태/정책 메모 + +- 제안 상태(`BattleProposalStatus`): + + | status | 설명 | + |--------|------| + | `PENDING` | 검토 대기 중 (기본값) | + | `ACCEPTED` | 채택 완료 → 제안자에게 100크레딧 지급 | + | `REJECTED` | 미채택 | + +- 크레딧 정책: + - 제안 등록 시: **-30크레딧** 차감 (`TOPIC_SUGGEST`) + - 채택 시: **+100크레딧** 지급 (`TOPIC_ADOPTED`) + - 크레딧 부족 시 제안 불가 (`CREDIT_NOT_ENOUGH`) +- `PENDING` 상태인 제안만 채택/미채택 처리 가능 + +--- + +## 4. 에러 코드 + +| Error Code | HTTP Status | 설명 | +|------------|:-----------:|------| +| `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | +| `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | +| `FORBIDDEN_ACCESS` | `403` | 접근 권한 없음 | +| `BATTLE_NOT_FOUND` | `404` | 존재하지 않는 제안 | +| `BATTLE_ALREADY_PUBLISHED` | `409` | 이미 처리된 제안 | +| `CREDIT_NOT_ENOUGH` | `400` | 크레딧 부족 | +| `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | \ No newline at end of file From fc227e24378470f3dfce82200edec0ad26409bc8 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:08:50 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/battle/entity/BattleProposal.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java diff --git a/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java b/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java new file mode 100644 index 0000000..468461c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/entity/BattleProposal.java @@ -0,0 +1,66 @@ +package com.swyp.picke.domain.battle.entity; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "battle_proposals") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BattleProposal extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private BattleCategory category; + + @Column(nullable = false) + private String topic; + + @Column(name = "position_a", nullable = false) + private String positionA; + + @Column(name = "position_b", nullable = false) + private String positionB; + + @Column(columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private BattleProposalStatus status; + + @Builder + public BattleProposal(User user, BattleCategory category, String topic, + String positionA, String positionB, String description) { + this.user = user; + this.category = category; + this.topic = topic; + this.positionA = positionA; + this.positionB = positionB; + this.description = description; + this.status = BattleProposalStatus.PENDING; + } + + public void accept() { + this.status = BattleProposalStatus.ACCEPTED; + } + + public void reject() { + this.status = BattleProposalStatus.REJECTED; + } +} From a126fb9320d6bdcc6f913dc388e55e2c92c5c874 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:09:53 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/BattleProposalRequest.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java new file mode 100644 index 0000000..324768c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalRequest.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.battle.dto.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class BattleProposalRequest { + + @NotNull(message = "카테고리를 선택해주세요") + private BattleCategory category; + + @NotBlank(message = "주제를 입력해주세요") + @Size(max = 100, message = "주제는 100자 이내로 입력해주세요") + private String topic; + + @NotBlank(message = "A 입장을 입력해주세요") + private String positionA; + + @NotBlank(message = "B 입장을 입력해주세요") + private String positionB; + + @Size(max = 200, message = "부가 설명은 200자 이내로 입력해주세요") + private String description; +} \ No newline at end of file From c5123ce0f6adcd6dd6a9ea066a5519ca04bc188f Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:12:25 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20enum=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/picke/domain/battle/enums/BattleProposalStatus.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java new file mode 100644 index 0000000..c5aa898 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleProposalStatus.java @@ -0,0 +1,5 @@ +package com.swyp.picke.domain.battle.enums; + +public enum BattleProposalStatus { + PENDING, ACCEPTED, REJECTED +} From e45d6fba7b9286859b8fba7d4ac5fa41165371a6 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:12:40 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/BattleProposalReviewRequest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java new file mode 100644 index 0000000..d67034e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/request/BattleProposalReviewRequest.java @@ -0,0 +1,15 @@ +package com.swyp.picke.domain.battle.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class BattleProposalReviewRequest { + + @NotNull(message = "action은 필수입니다") + private Action action; + + public enum Action { + ACCEPT, REJECT + } +} From b98c37fda892976e606e1f1aa9eecd74d12b7f59 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:12:55 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/BattleProposalResponse.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java new file mode 100644 index 0000000..030c948 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/response/BattleProposalResponse.java @@ -0,0 +1,35 @@ +package com.swyp.picke.domain.battle.dto.response; + +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class BattleProposalResponse { + private final Long id; + private final Long userId; + private final String nickname; + private final BattleCategory category; + private final String topic; + private final String positionA; + private final String positionB; + private final String description; + private final BattleProposalStatus status; + private final LocalDateTime createdAt; + + public BattleProposalResponse(BattleProposal proposal) { + this.id = proposal.getId(); + this.userId = proposal.getUser().getId(); + this.nickname = proposal.getUser().getNickname(); + this.category = proposal.getCategory(); + this.topic = proposal.getTopic(); + this.positionA = proposal.getPositionA(); + this.positionB = proposal.getPositionB(); + this.description = proposal.getDescription(); + this.status = proposal.getStatus(); + this.createdAt = proposal.getCreatedAt(); + } +} From d641b541fe0ddc4657773315d6a4f2457d727b02 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:13:16 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../battle/repository/BattleProposalRepository.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java new file mode 100644 index 0000000..9079b38 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleProposalRepository.java @@ -0,0 +1,13 @@ +package com.swyp.picke.domain.battle.repository; + +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BattleProposalRepository extends JpaRepository { + Page findAllByStatusOrderByCreatedAtDesc(BattleProposalStatus status, Pageable pageable); + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + Page findAllByStatus(BattleProposalStatus status, Pageable pageable); +} From e16d759191d7b118379e49bec9bfd6e2144093b8 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:13:27 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=9D=91=EB=8B=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/common/response/PageResponse.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/com/swyp/picke/global/common/response/PageResponse.java diff --git a/src/main/java/com/swyp/picke/global/common/response/PageResponse.java b/src/main/java/com/swyp/picke/global/common/response/PageResponse.java new file mode 100644 index 0000000..8a7a567 --- /dev/null +++ b/src/main/java/com/swyp/picke/global/common/response/PageResponse.java @@ -0,0 +1,26 @@ +package com.swyp.picke.global.common.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.domain.Page; + +@Getter +@AllArgsConstructor +public class PageResponse { + private List content; + private long totalElements; + private int totalPages; + private int page; + private int size; + + public static PageResponse of(Page page) { + return new PageResponse<>( + page.getContent(), + page.getTotalElements(), + page.getTotalPages(), + page.getNumber() + 1, + page.getSize() + ); + } +} From 1957235ed8036a6f6e1e69c7d775a7e86ea6fea5 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:15:08 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=ED=81=AC=EB=A0=88=EB=94=A7=20?= =?UTF-8?q?=EB=B6=80=EC=A1=B1=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/swyp/picke/global/common/exception/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java index c1f086b..ce7d66f 100644 --- a/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/picke/global/common/exception/ErrorCode.java @@ -37,6 +37,7 @@ public enum ErrorCode { // Credit CREDIT_REFERENCE_REQUIRED(HttpStatus.BAD_REQUEST, "CREDIT_400_REF", "크레딧 적립 referenceId는 필수입니다."), CREDIT_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CREDIT_500_SAVE", "크레딧 적립 처리 중 오류가 발생했습니다."), + CREDIT_NOT_ENOUGH(HttpStatus.BAD_REQUEST, "CREDIT_400_INSUFFICIENT", "크레딧이 부족합니다."), // OAuth (Social Login) INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_400_PROVIDER", "지원하지 않는 소셜 로그인 provider입니다."), From a75af577ea82d3b7074dad2e89c1319cd5f3dd94 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:15:25 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=20enum=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/battle/enums/BattleCategory.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java diff --git a/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java new file mode 100644 index 0000000..987865b --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/enums/BattleCategory.java @@ -0,0 +1,29 @@ +package com.swyp.picke.domain.battle.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum BattleCategory { + PHILOSOPHY("철학"), + LITERATURE("문학"), + ART("예술"), + SCIENCE("과학"), + SOCIETY("사회"), + HISTORY("역사"); + + private final String value; + BattleCategory(String value) { this.value = value; } + + @JsonCreator + public static BattleCategory from(String value) { + for (BattleCategory category : BattleCategory.values()) { + if (category.value.equals(value)) { + return category; + } + } + return null; + } + + @JsonValue + public String getValue() { return value; } +} \ No newline at end of file From 7363424f09a3e5a059ca4e2cd1125dbdbe04d827 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:15:31 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../battle/service/BattleProposalService.java | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java new file mode 100644 index 0000000..c8123e0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleProposalService.java @@ -0,0 +1,98 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.request.BattleProposalReviewRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.entity.BattleProposal; +import com.swyp.picke.domain.battle.enums.BattleProposalStatus; +import com.swyp.picke.domain.battle.repository.BattleProposalRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.common.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleProposalService { + + private final BattleProposalRepository battleProposalRepository; + private final CreditService creditService; + private final UserService userService; + + private static final int PROPOSAL_COST = 30; + private static final int PROPOSAL_REWARD = 100; + + @Transactional + public BattleProposalResponse propose(BattleProposalRequest request) { + User user = userService.findCurrentUser(); + + // 크레딧 잔액 확인 + int totalCredits = creditService.getTotalPoints(user.getId()); + if (totalCredits < PROPOSAL_COST) { + throw new CustomException(ErrorCode.CREDIT_NOT_ENOUGH); + } + + // 제안 저장 + BattleProposal proposal = BattleProposal.builder() + .user(user) + .category(request.getCategory()) + .topic(request.getTopic()) + .positionA(request.getPositionA()) + .positionB(request.getPositionB()) + .description(request.getDescription()) + .build(); + + battleProposalRepository.save(proposal); + + // 30크레딧 차감 (음수로 저장) + creditService.addCredit(user.getId(), CreditType.TOPIC_SUGGEST, -PROPOSAL_COST, proposal.getId()); + + return new BattleProposalResponse(proposal); + } + + public PageResponse getProposals(int page, int size, String status) { + int pageNumber = Math.max(0, page - 1); + Pageable pageable = PageRequest.of(pageNumber, size, Sort.by(Sort.Direction.DESC, "createdAt")); + + BattleProposalStatus proposalStatus = (status != null && !status.isEmpty()) + ? BattleProposalStatus.valueOf(status.toUpperCase()) + : null; + + Page proposals = (proposalStatus != null) + ? battleProposalRepository.findAllByStatus(proposalStatus, pageable) + : battleProposalRepository.findAll(pageable); + + return PageResponse.of(proposals.map(BattleProposalResponse::new)); + } + + @Transactional + public BattleProposalResponse review(Long proposalId, BattleProposalReviewRequest request) { + BattleProposal proposal = battleProposalRepository.findById(proposalId) + .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_NOT_FOUND)); + + if (proposal.getStatus() != BattleProposalStatus.PENDING) { + throw new CustomException(ErrorCode.BATTLE_ALREADY_PUBLISHED); + } + + if (request.getAction() == BattleProposalReviewRequest.Action.ACCEPT) { + proposal.accept(); + // 100크레딧 지급 + creditService.addCredit(proposal.getUser().getId(), CreditType.TOPIC_ADOPTED, PROPOSAL_REWARD, proposalId); + } else { + proposal.reject(); + } + + return new BattleProposalResponse(proposal); + } +} From 807b0a259617c7e6856b492eb5ac4ebc541f3d56 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:15:46 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminBattleProposalController.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java new file mode 100644 index 0000000..04f7fa2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/AdminBattleProposalController.java @@ -0,0 +1,41 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.dto.request.BattleProposalReviewRequest; +import com.swyp.picke.domain.battle.service.BattleProposalService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "관리자 배틀 제안 API", description = "주제 제안 목록 조회 및 채택/미채택 처리") +@RestController +@RequestMapping("/api/v1/admin/battles") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminBattleProposalController { + + private final BattleProposalService battleProposalService; + + @Operation(summary = "배틀 주제 제안 목록 조회") + @GetMapping("/proposals") + public ApiResponse getProposals( + @RequestParam(value = "page", defaultValue = "1") int page, + @RequestParam(value = "size", defaultValue = "10") int size, + @RequestParam(value = "status", required = false) String status + ) { + return ApiResponse.onSuccess(battleProposalService.getProposals(page, size, status)); + } + + @Operation(summary = "배틀 주제 채택/미채택 처리") + @PatchMapping("/proposals/{proposalId}") + public ApiResponse review( + @PathVariable Long proposalId, + @Valid @RequestBody BattleProposalReviewRequest request + ) { + return ApiResponse.onSuccess(battleProposalService.review(proposalId, request)); + } +} From 00c1011d4a2b9411be35635229a171d4107fdc85 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:15:54 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8B=80=20=EC=A0=9C?= =?UTF-8?q?=EC=95=88=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BattleProposalController.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java diff --git a/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java b/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java new file mode 100644 index 0000000..66261c3 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/controller/BattleProposalController.java @@ -0,0 +1,28 @@ +package com.swyp.picke.domain.battle.controller; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.service.BattleProposalService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "배틀 제안 API", description = "배틀 제안") +@RestController +@RequestMapping("/api/v1/battles") +@RequiredArgsConstructor +public class BattleProposalController { + + private final BattleProposalService battleProposalService; + + @Operation(summary = "배틀 주제 제안") + @PostMapping("/proposals") + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse propose(@Valid @RequestBody BattleProposalRequest request) { + return ApiResponse.onSuccess(battleProposalService.propose(request)); + } +} From 923ad68b692e42eca621bbf99d41119cd60ba671 Mon Sep 17 00:00:00 2001 From: si-zero Date: Tue, 14 Apr 2026 23:25:05 +0900 Subject: [PATCH 14/14] =?UTF-8?q?test:=20=EB=B0=B0=ED=8B=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/BattleProposalServiceTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java new file mode 100644 index 0000000..53b664e --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleProposalServiceTest.java @@ -0,0 +1,85 @@ +package com.swyp.picke.domain.battle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.swyp.picke.domain.battle.dto.request.BattleProposalRequest; +import com.swyp.picke.domain.battle.dto.response.BattleProposalResponse; +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.repository.BattleProposalRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserService; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BattleProposalServiceTest { + + @InjectMocks + private BattleProposalService battleProposalService; + + @Mock + private BattleProposalRepository battleProposalRepository; + + @Mock + private CreditService creditService; + + @Mock + private UserService userService; + + @Test + @DisplayName("1. 배틀 제안 성공 - 크레딧 차감 및 저장 확인") + void propose_Success() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userService.findCurrentUser()).willReturn(user); + given(creditService.getTotalPoints(1L)).willReturn(100); // 잔액 충분 + + BattleProposalRequest request = mock(BattleProposalRequest.class); + given(request.getCategory()).willReturn(BattleCategory.PHILOSOPHY); + given(request.getTopic()).willReturn("테스트 주제"); + + // when + BattleProposalResponse response = battleProposalService.propose(request); + + // then + // 제안 저장 메서드가 호출되었는지 확인 + verify(battleProposalRepository, times(1)).save(any()); + // 크레딧 차감(-30) 로직이 호출되었는지 확인 + verify(creditService, times(1)).addCredit(eq(1L), eq(CreditType.TOPIC_SUGGEST), eq(-30), any()); + } + + @Test + @DisplayName("2. 배틀 제안 실패 - 크레딧 부족 시 예외 발생") + void propose_Fail_CreditNotEnough() { + // given + User user = mock(User.class); + given(user.getId()).willReturn(1L); + given(userService.findCurrentUser()).willReturn(user); + given(creditService.getTotalPoints(1L)).willReturn(10); // 잔액 부족 (30 미만) + + BattleProposalRequest request = mock(BattleProposalRequest.class); + + // when & then + // 에러 코드 CREDIT_NOT_ENOUGH가 발생하는지 확인 + CustomException exception = assertThrows(CustomException.class, () -> { + battleProposalService.propose(request); + }); + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.CREDIT_NOT_ENOUGH); + } +}