Skip to content
Open
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
81 changes: 81 additions & 0 deletions docs/api-specs/perspectives-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@

- 관점 API 입니다.
- 현재 Creator 뱃지 부분이 ERD 상에선 보이지 않는데 확인 필요

### 관점 상태(status) 흐름

| status | 설명 |
|--------|------|
| `PENDING` | 생성/수정 직후, GPT 검수 대기 중 |
| `PUBLISHED` | GPT 검수 통과, 목록에 노출됨 |
| `REJECTED` | GPT 검수 거절 (욕설/공격적 표현 포함) |
| `MODERATION_FAILED` | GPT API 호출 실패 (네트워크 오류 등), 재시도 가능 |

```
생성/수정 → PENDING → GPT 호출 성공 → APPROVED → PUBLISHED
→ REJECT → REJECTED
→ GPT 호출 실패 → 1회 재시도
→ 재시도 실패 → MODERATION_FAILED
↓ (재시도 버튼)
PENDING → GPT 재호출
```

---

## 관점 생성 API
Expand Down Expand Up @@ -268,6 +287,67 @@

---

## 관점 검수 재시도 API
### `POST /api/v1/perspectives/{perspective_id}/moderation/retry`

- `MODERATION_FAILED` 상태의 관점에 대해 GPT 검수를 다시 요청합니다.
- 재시도 후 상태는 `PENDING`으로 변경되며, GPT 응답에 따라 `PUBLISHED` / `REJECTED` / `MODERATION_FAILED`로 전환됩니다.
- 재시도도 실패하면 다시 `MODERATION_FAILED`로 남습니다.

#### 성공 응답 `200 OK`

```json
{
"statusCode": 200,
"data": null,
"error": null
}
```

#### 예외 응답 `404 - 관점 없음`

```json
{
"statusCode": 404,
"data": null,
"error": {
"code": "PERSPECTIVE_NOT_FOUND",
"message": "존재하지 않는 관점입니다.",
"errors": []
}
}
```

#### 예외 응답 `400 - 검수 실패 상태 아님`

```json
{
"statusCode": 400,
"data": null,
"error": {
"code": "PERSPECTIVE_400",
"message": "검수 실패 상태의 관점이 아닙니다.",
"errors": []
}
}
```

#### 예외 응답 `403 - 본인 관점 아님`

```json
{
"statusCode": 403,
"data": null,
"error": {
"code": "PERSPECTIVE_403",
"message": "본인 관점만 수정/삭제할 수 있습니다.",
"errors": []
}
}
```

---

## 공통 에러 코드

| Error Code | HTTP Status | 설명 |
Expand All @@ -290,5 +370,6 @@
| `PERSPECTIVE_ALREADY_EXISTS` | `409` | 해당 배틀에 이미 관점 작성함 |
| `PERSPECTIVE_FORBIDDEN` | `403` | 본인 관점 아님 |
| `PERSPECTIVE_POST_VOTE_REQUIRED` | `409` | 사후 투표 미완료 |
| `PERSPECTIVE_400` | `400` | 검수 실패 상태의 관점이 아님 (재시도 불가) |

---
2 changes: 2 additions & 0 deletions src/main/java/com/swyp/app/AppApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;

@EnableJpaAuditing
@SpringBootApplication
@EnableAsync
public class AppApplication {
public static void main(String[] args) {
SpringApplication.run(AppApplication.class, args);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ public ApiResponse<Void> deletePerspective(@PathVariable UUID perspectiveId) {
return ApiResponse.onSuccess(null);
}

@Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.")
@PostMapping("/perspectives/{perspectiveId}/moderation/retry")
public ApiResponse<Void> retryModeration(@PathVariable UUID perspectiveId) {
// TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체
Long userId = 1L;
perspectiveService.retryModeration(perspectiveId, userId);
return ApiResponse.onSuccess(null);
}

@Operation(summary = "관점 수정", description = "본인이 작성한 관점의 내용을 수정합니다.")
@PatchMapping("/perspectives/{perspectiveId}")
public ApiResponse<UpdatePerspectiveResponse> updatePerspective(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.swyp.app.domain.perspective.dto.response;

import com.swyp.app.domain.user.entity.CharacterType;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
Expand All @@ -17,5 +19,5 @@ public record Item(
LocalDateTime createdAt
) {}

public record UserSummary(String userTag, String nickname, String characterUrl) {}
public record UserSummary(String userTag, String nickname, CharacterType characterType) {}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.swyp.app.domain.perspective.dto.response;

import com.swyp.app.domain.user.entity.CharacterType;

import java.time.LocalDateTime;
import java.util.UUID;

Expand All @@ -9,5 +11,5 @@ public record CreateCommentResponse(
String content,
LocalDateTime createdAt
) {
public record UserSummary(String userTag, String nickname, String characterUrl) {}
public record UserSummary(String userTag, String nickname, CharacterType characterType) {}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.swyp.app.domain.perspective.dto.response;

import com.swyp.app.domain.user.entity.CharacterType;

import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
Expand All @@ -23,7 +25,7 @@ public record Item(
public record UserSummary(
String userTag,
String nickname,
String characterUrl
CharacterType characterType
) {}

public record OptionSummary(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ public void updateContent(String content) {
this.content = content;
}

public void updateStatus(PerspectiveStatus status) {
this.status = status;
}

public void publish() {
this.status = PerspectiveStatus.PUBLISHED;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package com.swyp.app.domain.perspective.entity;

public enum PerspectiveStatus {
PENDING, PUBLISHED, REJECTED
PENDING, PUBLISHED, REJECTED, MODERATION_FAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.swyp.app.domain.perspective.service;

import com.swyp.app.domain.perspective.entity.PerspectiveStatus;
import com.swyp.app.domain.perspective.repository.PerspectiveRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.util.List;
import java.util.Map;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class GptModerationService {

// 프롬프트는 추후 결정
private static final String SYSTEM_PROMPT =
"당신은 콘텐츠 검수 AI입니다. 입력된 텍스트에 욕설, 혐오 발언, 폭력적 표현, 성적 표현, 특정인을 향한 공격적 내용이 포함되어 있는지 판단하세요. " +
"문제가 있으면 'REJECT', 없으면 'APPROVE' 딱 한 단어만 응답하세요.";

private static final int MAX_ATTEMPTS = 2;
private static final int CONNECT_TIMEOUT_MS = 5000;
private static final int READ_TIMEOUT_MS = 10000;

private final PerspectiveRepository perspectiveRepository;

@Value("${openai.api-key}")
private String apiKey;

@Value("${openai.url}")
private String openaiUrl;

@Value("${openai.model}")
private String model;

@Async
public void moderate(UUID perspectiveId, String content) {
Exception lastException = null;
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
String result = callGpt(content);
PerspectiveStatus newStatus = result.contains("APPROVE")
? PerspectiveStatus.PUBLISHED
: PerspectiveStatus.REJECTED;

perspectiveRepository.findById(perspectiveId).ifPresent(p -> {
if (p.getStatus() == PerspectiveStatus.PENDING) {
if (newStatus == PerspectiveStatus.PUBLISHED) p.publish();
else p.reject();
perspectiveRepository.save(p);
}
});
return;
} catch (Exception e) {
lastException = e;
if (attempt < MAX_ATTEMPTS) {
try { Thread.sleep(2000); } catch (InterruptedException ie) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요친구도 상수화 해서 관리하면 좋을 것 같아요!

Thread.currentThread().interrupt();
break;
}
}
}
}
log.error("GPT 검수 최종 실패 (재시도 소진). perspectiveId={}", perspectiveId, lastException);
perspectiveRepository.findById(perspectiveId).ifPresent(p -> {
if (p.getStatus() == PerspectiveStatus.PENDING) {
p.updateStatus(PerspectiveStatus.MODERATION_FAILED);
perspectiveRepository.save(p);
}
});
}

private String callGpt(String content) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);
factory.setReadTimeout(READ_TIMEOUT_MS);
RestClient restClient = RestClient.builder().requestFactory(factory).build();

Map<String, Object> requestBody = Map.of(
"model", model,
"messages", List.of(
Map.of("role", "system", "content", SYSTEM_PROMPT),
Map.of("role", "user", "content", content)
),
"max_tokens", 10
);

Map response = restClient.post()
.uri(openaiUrl)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(requestBody)
.retrieve()
.body(Map.class);

List choices = (List) response.get("choices");
Map choice = (Map) choices.get(0);
Map message = (Map) choice.get("message");
return ((String) message.get("content")).trim().toUpperCase();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import com.swyp.app.domain.perspective.entity.PerspectiveComment;
import com.swyp.app.domain.perspective.repository.PerspectiveCommentRepository;
import com.swyp.app.domain.perspective.repository.PerspectiveRepository;
import com.swyp.app.domain.user.service.UserQueryService;
import com.swyp.app.domain.user.dto.response.UserSummary;
import com.swyp.app.domain.user.service.UserService;
import com.swyp.app.global.common.exception.CustomException;
import com.swyp.app.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
Expand All @@ -30,7 +31,7 @@ public class PerspectiveCommentService {

private final PerspectiveRepository perspectiveRepository;
private final PerspectiveCommentRepository commentRepository;
private final UserQueryService userQueryService;
private final UserService userQueryService;

@Transactional
public CreateCommentResponse createComment(UUID perspectiveId, Long userId, CreateCommentRequest request) {
Expand All @@ -45,10 +46,10 @@ public CreateCommentResponse createComment(UUID perspectiveId, Long userId, Crea
commentRepository.save(comment);
perspective.incrementCommentCount();

UserQueryService.UserSummary user = userQueryService.findSummaryById(userId);
UserSummary user = userQueryService.findSummaryById(userId);
return new CreateCommentResponse(
comment.getId(),
new CreateCommentResponse.UserSummary(user.userTag(), user.nickname(), user.characterUrl()),
new CreateCommentResponse.UserSummary(user.userTag(), user.nickname(), user.characterType()),
comment.getContent(),
comment.getCreatedAt()
);
Expand All @@ -67,10 +68,10 @@ public CommentListResponse getComments(UUID perspectiveId, Long userId, String c

List<CommentListResponse.Item> items = comments.stream()
.map(c -> {
UserQueryService.UserSummary user = userQueryService.findSummaryById(c.getUserId());
UserSummary user = userQueryService.findSummaryById(c.getUserId());
return new CommentListResponse.Item(
c.getId(),
new CommentListResponse.UserSummary(user.userTag(), user.nickname(), user.characterUrl()),
new CommentListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType()),
c.getContent(),
c.getUserId().equals(userId),
c.getCreatedAt()
Expand Down
Loading