From db8791a8474fc21575a563d8a9b05a24c2034e0e Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Sun, 15 Mar 2026 17:05:22 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[Fix]=20feat/#15=EA=B3=BC=20Merge=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=EC=9E=84=EC=8B=9C=20User=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=EA=B3=BC=20=EA=B8=B0=EC=A1=B4=20User=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9D=98=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EC=A1=B4=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CommentListResponse.java | 4 +++- .../dto/response/CreateCommentResponse.java | 4 +++- .../dto/response/PerspectiveListResponse.java | 4 +++- .../service/PerspectiveCommentService.java | 13 ++++++----- .../service/PerspectiveService.java | 9 ++++---- .../domain/user/dto/response/UserSummary.java | 5 +++++ .../user/repository/UserRepository.java | 3 +-- .../domain/user/service/UserQueryService.java | 8 ------- .../user/service/UserQueryServiceImpl.java | 22 ------------------- .../app/domain/user/service/UserService.java | 8 +++++++ .../global/common/exception/ErrorCode.java | 4 +--- 11 files changed, 36 insertions(+), 48 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/UserSummary.java delete mode 100644 src/main/java/com/swyp/app/domain/user/service/UserQueryService.java delete mode 100644 src/main/java/com/swyp/app/domain/user/service/UserQueryServiceImpl.java diff --git a/src/main/java/com/swyp/app/domain/perspective/dto/response/CommentListResponse.java b/src/main/java/com/swyp/app/domain/perspective/dto/response/CommentListResponse.java index fb7e85b..d334bb8 100644 --- a/src/main/java/com/swyp/app/domain/perspective/dto/response/CommentListResponse.java +++ b/src/main/java/com/swyp/app/domain/perspective/dto/response/CommentListResponse.java @@ -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; @@ -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) {} } diff --git a/src/main/java/com/swyp/app/domain/perspective/dto/response/CreateCommentResponse.java b/src/main/java/com/swyp/app/domain/perspective/dto/response/CreateCommentResponse.java index 3709f6b..0d2123e 100644 --- a/src/main/java/com/swyp/app/domain/perspective/dto/response/CreateCommentResponse.java +++ b/src/main/java/com/swyp/app/domain/perspective/dto/response/CreateCommentResponse.java @@ -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; @@ -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) {} } diff --git a/src/main/java/com/swyp/app/domain/perspective/dto/response/PerspectiveListResponse.java b/src/main/java/com/swyp/app/domain/perspective/dto/response/PerspectiveListResponse.java index a5e535a..03f28d6 100644 --- a/src/main/java/com/swyp/app/domain/perspective/dto/response/PerspectiveListResponse.java +++ b/src/main/java/com/swyp/app/domain/perspective/dto/response/PerspectiveListResponse.java @@ -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; @@ -23,7 +25,7 @@ public record Item( public record UserSummary( String userTag, String nickname, - String characterUrl + CharacterType characterType ) {} public record OptionSummary( diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java index aafddd3..ef8bf1c 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java @@ -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; @@ -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) { @@ -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() ); @@ -67,10 +68,10 @@ public CommentListResponse getComments(UUID perspectiveId, Long userId, String c List 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() diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java index 85bca44..80d3244 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java @@ -13,7 +13,8 @@ import com.swyp.app.domain.perspective.entity.Perspective; import com.swyp.app.domain.perspective.repository.PerspectiveLikeRepository; 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.domain.vote.service.VoteService; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; @@ -37,7 +38,7 @@ public class PerspectiveService { private final PerspectiveLikeRepository perspectiveLikeRepository; private final BattleService battleService; private final VoteService voteService; - private final UserQueryService userQueryService; + private final UserService userQueryService; @Transactional public CreatePerspectiveResponse createPerspective(UUID battleId, Long userId, CreatePerspectiveRequest request) { @@ -82,12 +83,12 @@ public PerspectiveListResponse getPerspectives(UUID battleId, Long userId, Strin List items = perspectives.stream() .map(p -> { - UserQueryService.UserSummary user = userQueryService.findSummaryById(p.getUserId()); + UserSummary user = userQueryService.findSummaryById(p.getUserId()); BattleOption option = battleService.findOptionById(p.getOptionId()); boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(p, userId); return new PerspectiveListResponse.Item( p.getId(), - new PerspectiveListResponse.UserSummary(user.userTag(), user.nickname(), user.characterUrl()), + new PerspectiveListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType()), new PerspectiveListResponse.OptionSummary(option.getId(), option.getLabel().name(), option.getTitle()), p.getContent(), p.getLikeCount(), diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/UserSummary.java b/src/main/java/com/swyp/app/domain/user/dto/response/UserSummary.java new file mode 100644 index 0000000..0a193bd --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/UserSummary.java @@ -0,0 +1,5 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.CharacterType; + +public record UserSummary(String userTag, String nickname, CharacterType characterType) {} diff --git a/src/main/java/com/swyp/app/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/app/domain/user/repository/UserRepository.java index 3e430c8..6e0b366 100644 --- a/src/main/java/com/swyp/app/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/app/domain/user/repository/UserRepository.java @@ -2,8 +2,6 @@ import com.swyp.app.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; - -public interface UserRepository extends JpaRepository { import java.util.Optional; public interface UserRepository extends JpaRepository { @@ -11,3 +9,4 @@ public interface UserRepository extends JpaRepository { Optional findTopByOrderByIdDesc(); boolean existsByUserTag(String userTag); } + diff --git a/src/main/java/com/swyp/app/domain/user/service/UserQueryService.java b/src/main/java/com/swyp/app/domain/user/service/UserQueryService.java deleted file mode 100644 index 7cfa195..0000000 --- a/src/main/java/com/swyp/app/domain/user/service/UserQueryService.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.swyp.app.domain.user.service; - -public interface UserQueryService { - - UserSummary findSummaryById(Long userId); - - record UserSummary(String userTag, String nickname, String characterUrl) {} -} diff --git a/src/main/java/com/swyp/app/domain/user/service/UserQueryServiceImpl.java b/src/main/java/com/swyp/app/domain/user/service/UserQueryServiceImpl.java deleted file mode 100644 index cf2aefe..0000000 --- a/src/main/java/com/swyp/app/domain/user/service/UserQueryServiceImpl.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.swyp.app.domain.user.service; - -import com.swyp.app.domain.user.entity.User; -import com.swyp.app.domain.user.repository.UserRepository; -import com.swyp.app.global.common.exception.CustomException; -import com.swyp.app.global.common.exception.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class UserQueryServiceImpl implements UserQueryService { - - private final UserRepository userRepository; - - @Override - public UserSummary findSummaryById(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - return new UserSummary(user.getUserTag(), user.getNickname(), user.getCharacterUrl()); - } -} diff --git a/src/main/java/com/swyp/app/domain/user/service/UserService.java b/src/main/java/com/swyp/app/domain/user/service/UserService.java index 6332c48..5bccb04 100644 --- a/src/main/java/com/swyp/app/domain/user/service/UserService.java +++ b/src/main/java/com/swyp/app/domain/user/service/UserService.java @@ -13,6 +13,7 @@ import com.swyp.app.domain.user.dto.response.UpdateResultResponse; import com.swyp.app.domain.user.dto.response.UserProfileResponse; import com.swyp.app.domain.user.dto.response.UserSettingsResponse; +import com.swyp.app.domain.user.dto.response.UserSummary; import com.swyp.app.domain.user.entity.AgreementType; import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.user.entity.UserAgreement; @@ -231,6 +232,13 @@ public TendencyScoreHistoryResponse getMyTendencyScoreHistory(Long cursor, Integ return new TendencyScoreHistoryResponse(items, nextCursor); } + public UserSummary findSummaryById(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + UserProfile profile = findUserProfile(user.getId()); + return new UserSummary(user.getUserTag(), profile.getNickname(), profile.getCharacterType()); + } + private User findUserByTag(String userTag) { return userRepository.findByUserTag(userTag) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); diff --git a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java index 313c074..d780280 100644 --- a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java @@ -14,6 +14,7 @@ public enum ErrorCode { // User USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 유저입니다."), + ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409", "이미 온보딩이 완료된 사용자입니다."), // Battle & Tag BATTLE_NOT_FOUND(HttpStatus.NOT_FOUND, "BATTLE_404", "존재하지 않는 배틀입니다."), @@ -36,9 +37,6 @@ public enum ErrorCode { // Vote VOTE_NOT_FOUND(HttpStatus.NOT_FOUND, "VOTE_404", "투표 내역이 없습니다."); - // User - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."), - ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "USER_409", "이미 온보딩이 완료된 사용자입니다."); private final HttpStatus httpStatus; private final String code; From fe7982deba6da6912d3ec7becb1777bd935a71ba Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Sun, 15 Mar 2026 19:47:19 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[Feat]=20=EA=B4=80=EC=A0=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20,=20=EC=88=98=EC=A0=95=20GPT=20=EA=B2=80=EC=88=98?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/perspectives-api.md | 81 +++++++++++++ .../java/com/swyp/app/AppApplication.java | 2 + .../controller/PerspectiveController.java | 9 ++ .../perspective/entity/Perspective.java | 4 + .../perspective/entity/PerspectiveStatus.java | 2 +- .../service/GptModerationService.java | 107 ++++++++++++++++++ .../service/PerspectiveService.java | 15 +++ .../global/common/exception/ErrorCode.java | 1 + .../swyp/app/global/config/AsyncConfig.java | 9 ++ src/main/resources/application.yml | 5 + 10 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/swyp/app/domain/perspective/service/GptModerationService.java create mode 100644 src/main/java/com/swyp/app/global/config/AsyncConfig.java diff --git a/docs/api-specs/perspectives-api.md b/docs/api-specs/perspectives-api.md index dd60b8f..5b445aa 100644 --- a/docs/api-specs/perspectives-api.md +++ b/docs/api-specs/perspectives-api.md @@ -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 @@ -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 | 설명 | @@ -290,5 +370,6 @@ | `PERSPECTIVE_ALREADY_EXISTS` | `409` | 해당 배틀에 이미 관점 작성함 | | `PERSPECTIVE_FORBIDDEN` | `403` | 본인 관점 아님 | | `PERSPECTIVE_POST_VOTE_REQUIRED` | `409` | 사후 투표 미완료 | +| `PERSPECTIVE_400` | `400` | 검수 실패 상태의 관점이 아님 (재시도 불가) | --- \ No newline at end of file diff --git a/src/main/java/com/swyp/app/AppApplication.java b/src/main/java/com/swyp/app/AppApplication.java index 01062c0..ce684d0 100644 --- a/src/main/java/com/swyp/app/AppApplication.java +++ b/src/main/java/com/swyp/app/AppApplication.java @@ -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); diff --git a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java index e0b98bd..b8b64b3 100644 --- a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java @@ -74,6 +74,15 @@ public ApiResponse deletePerspective(@PathVariable UUID perspectiveId) { return ApiResponse.onSuccess(null); } + @Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") + @PostMapping("/perspectives/{perspectiveId}/moderation/retry") + public ApiResponse 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 updatePerspective( diff --git a/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java b/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java index b9cee98..61c5171 100644 --- a/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java +++ b/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java @@ -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; } diff --git a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveStatus.java b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveStatus.java index 21f7ae5..3613c54 100644 --- a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveStatus.java +++ b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveStatus.java @@ -1,5 +1,5 @@ package com.swyp.app.domain.perspective.entity; public enum PerspectiveStatus { - PENDING, PUBLISHED, REJECTED + PENDING, PUBLISHED, REJECTED, MODERATION_FAILED } diff --git a/src/main/java/com/swyp/app/domain/perspective/service/GptModerationService.java b/src/main/java/com/swyp/app/domain/perspective/service/GptModerationService.java new file mode 100644 index 0000000..1a3640a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/perspective/service/GptModerationService.java @@ -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) { + 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 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(); + } +} diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java index 80d3244..e56ac79 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java @@ -39,6 +39,7 @@ public class PerspectiveService { private final BattleService battleService; private final VoteService voteService; private final UserService userQueryService; + private final GptModerationService gptModerationService; @Transactional public CreatePerspectiveResponse createPerspective(UUID battleId, Long userId, CreatePerspectiveRequest request) { @@ -58,6 +59,7 @@ public CreatePerspectiveResponse createPerspective(UUID battleId, Long userId, C .build(); Perspective saved = perspectiveRepository.save(perspective); + gptModerationService.moderate(saved.getId(), saved.getContent()); return new CreatePerspectiveResponse(saved.getId(), saved.getStatus(), saved.getCreatedAt()); } @@ -118,6 +120,8 @@ public UpdatePerspectiveResponse updatePerspective(UUID perspectiveId, Long user Perspective perspective = findPerspectiveById(perspectiveId); validateOwnership(perspective, userId); perspective.updateContent(request.content()); + perspective.updateStatus(PerspectiveStatus.PENDING); + gptModerationService.moderate(perspective.getId(), perspective.getContent()); return new UpdatePerspectiveResponse(perspective.getId(), perspective.getContent(), perspective.getUpdatedAt()); } @@ -134,6 +138,17 @@ public MyPerspectiveResponse getMyPendingPerspective(UUID battleId, Long userId) ); } + @Transactional + public void retryModeration(UUID perspectiveId, Long userId) { + Perspective perspective = findPerspectiveById(perspectiveId); + validateOwnership(perspective, userId); + if (perspective.getStatus() != PerspectiveStatus.MODERATION_FAILED) { + throw new CustomException(ErrorCode.PERSPECTIVE_MODERATION_NOT_FAILED); + } + perspective.updateStatus(PerspectiveStatus.PENDING); + gptModerationService.moderate(perspectiveId, perspective.getContent()); + } + private Perspective findPerspectiveById(UUID perspectiveId) { return perspectiveRepository.findById(perspectiveId) .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); diff --git a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java index d780280..5883201 100644 --- a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java @@ -25,6 +25,7 @@ public enum ErrorCode { PERSPECTIVE_ALREADY_EXISTS(HttpStatus.CONFLICT, "PERSPECTIVE_409", "이미 관점을 작성한 배틀입니다."), PERSPECTIVE_FORBIDDEN(HttpStatus.FORBIDDEN, "PERSPECTIVE_403", "본인 관점만 수정/삭제할 수 있습니다."), PERSPECTIVE_POST_VOTE_REQUIRED(HttpStatus.CONFLICT, "PERSPECTIVE_VOTE_409", "사후 투표가 완료되지 않았습니다."), + PERSPECTIVE_MODERATION_NOT_FAILED(HttpStatus.BAD_REQUEST, "PERSPECTIVE_400", "검수 실패 상태의 관점이 아닙니다."), // Comment COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_404", "존재하지 않는 댓글입니다."), diff --git a/src/main/java/com/swyp/app/global/config/AsyncConfig.java b/src/main/java/com/swyp/app/global/config/AsyncConfig.java new file mode 100644 index 0000000..439a397 --- /dev/null +++ b/src/main/java/com/swyp/app/global/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.swyp.app.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1607f89..8997986 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,6 +17,11 @@ spring: jackson: property-naming-strategy: SNAKE_CASE +openai: + api-key: ${OPENAI_API_KEY} + url: https://api.openai.com/v1/chat/completions + model: gpt-4o-mini + springdoc: default-consumes-media-type: application/json default-produces-media-type: application/json