Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/api-specs/battle-proposal-api.md
Original file line number Diff line number Diff line change
@@ -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` | 서버 오류 |
Original file line number Diff line number Diff line change
@@ -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<BattleProposalResponse> review(
@PathVariable Long proposalId,
@Valid @RequestBody BattleProposalReviewRequest request
) {
return ApiResponse.onSuccess(battleProposalService.review(proposalId, request));
}
}
Original file line number Diff line number Diff line change
@@ -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<BattleProposalResponse> propose(@Valid @RequestBody BattleProposalRequest request) {
return ApiResponse.onSuccess(battleProposalService.propose(request));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.swyp.picke.domain.battle.enums;

public enum BattleProposalStatus {
PENDING, ACCEPTED, REJECTED
}
Loading