diff --git a/build.gradle b/build.gradle index 451c26b64..591fc761c 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,10 @@ dependencies { // LogStash implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + + // Spring AI - Google AI(Gemini) 연동 + implementation platform("org.springframework.ai:spring-ai-bom:1.0.0-M6") + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/common/ai/adapter/out/GeminiAdapter.java b/src/main/java/konkuk/thip/common/ai/adapter/out/GeminiAdapter.java new file mode 100644 index 000000000..6510c8821 --- /dev/null +++ b/src/main/java/konkuk/thip/common/ai/adapter/out/GeminiAdapter.java @@ -0,0 +1,94 @@ +package konkuk.thip.common.ai.adapter.out; + +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.ai.application.out.GeminiLoadPort; +import konkuk.thip.common.exception.InternalServerException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.roompost.domain.Record; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class GeminiAdapter implements GeminiLoadPort { + + private final ChatClient chatClient; + + private static final String TEMPLATE = """ + 시스템: 당신은 한국어 글쓰기 튜터이자 서평가이다. 사용자의 기록을 바탕으로 논리적이고 깔끔한 독서 감상문을 작성한다. + + 어투/스타일 규칙: + - 시점: 1인칭(저는/제가) + - 종결어미: 일관된 존댓말(~습니다/~합니다) + - 문장은 간결하고 매 단락의 주제가 분명해야 함 + - 과도한 추측/단정 금지, 기록에 근거하여 주장 전개 + - 필요 시 (p.페이지)로 근거 표기 + + 책 메타데이터: + - 책 제목: {bookTitle} + - 책 설명: {bookDescription} + + 사용자 정보: + - 닉네임: {nickname} + - 칭호: {alias} + - 현재 누적 독후감 생성 횟수: {reviewCount}회 + + 입력 기록(페이지 오름차순, 요약/정제됨): + {records} + + 작성 지시: + 1) 서론-본론-결론 3단 구성으로 작성합니다. + 2) 서론: 책 설명을 바탕으로 이번 독서의 관점/목표를 간단히 밝힙니다. + 3) 본론: 기록에서 드러난 핵심 주제 2~3가지를 선택해 근거와 함께 설명합니다. 필요한 곳에 (p.xx) 표기. + 4) 결론: 독서 전후의 생각 변화/적용 계획을 간단히 정리합니다. + 5) 전체 분량은 대략 {minLen}~{maxLen}자 수준으로 맞춥니다. + + 이제 위 정보를 바탕으로 독서 감상문을 작성하세요. + """; + + @Override + public String generateRecordReview(User user, List records, Book book, int minLength, int maxLength) { + // 방어 로직: records 비어있을 수 있음 (상위 유효성에서 걸러도 한 번 더 안전장치) + if (records == null || records.isEmpty()) { + throw new InternalServerException(ErrorCode.GEMINI_API_REQUEST_ERROR, + new IllegalArgumentException("기록이 비어있습니다.")); + } + + String recordsBlock = records.stream() + .map(r -> "- p." + r.getPage() + ": " + r.getContent()) + .collect(Collectors.joining("\n")); + + String bookTitle = book.getTitle(); + String bookDesc = book.getDescription() == null ? "설명 없음" : book.getDescription(); + + // 템플릿 렌더링 + String prompt = new PromptTemplate(TEMPLATE).render(Map.of( + "bookTitle", bookTitle, + "bookDescription", bookDesc, + "nickname", user.getNickname(), + "alias", user.getAlias().getValue(), + "reviewCount", String.valueOf(user.getRecordReviewCount()), + "records", recordsBlock, + "minLen", minLength, // 필요 시 파라미터화 + "maxLen", maxLength // 필요 시 파라미터화 + )); + + try { + // Spring AI ChatClient 호출 + return chatClient + .prompt() + .user(prompt) + .call() + .content(); + } catch (Exception e) { + throw new InternalServerException(ErrorCode.GEMINI_API_RESPONSE_ERROR, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/common/ai/application/out/GeminiLoadPort.java b/src/main/java/konkuk/thip/common/ai/application/out/GeminiLoadPort.java new file mode 100644 index 000000000..c43b73610 --- /dev/null +++ b/src/main/java/konkuk/thip/common/ai/application/out/GeminiLoadPort.java @@ -0,0 +1,11 @@ +package konkuk.thip.common.ai.application.out; + +import konkuk.thip.book.domain.Book; +import konkuk.thip.roompost.domain.Record; +import konkuk.thip.user.domain.User; + +import java.util.List; + +public interface GeminiLoadPort { + String generateRecordReview(User user, List records, Book book, int minLength, int maxLength); +} diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 3bd6ee7b7..b7fb384c0 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -54,6 +54,7 @@ public enum ErrorCode implements ResponseCode { USER_CANNOT_DELETE_ROOM_HOST(HttpStatus.BAD_REQUEST, 70009, "모집/진행 중인 방의 방장은 회원탈퇴를 할 수 없습니다."), USER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, 70010, "이미 삭제된 사용자 입니다."), USER_OAUTH2ID_CANNOT_BE_NULL(HttpStatus.INTERNAL_SERVER_ERROR, 70011, "유저의 OAuth2Id 값이 null일 수 없습니다."), + USER_RECORD_REVIEW_COUNT_EXCEEDS_LIMIT(HttpStatus.BAD_REQUEST, 70012, "사용자의 독후감 작성 수가 5회를 초과할 수 없습니다."), /** * 75000 : follow error @@ -134,6 +135,7 @@ public enum ErrorCode implements ResponseCode { RECORD_CANNOT_BE_OVERVIEW(HttpStatus.BAD_REQUEST, 130001, "총평이 될 수 없는 RECORD 입니다."), INVALID_RECORD_PAGE_RANGE(HttpStatus.BAD_REQUEST, 130002, "RECORD의 page 값이 유효하지 않습니다."), RECORD_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 130003, "기록 접근 권한이 없습니다."), + RECORD_REVIEW_NOT_ENOUGH_RECORDS(HttpStatus.BAD_REQUEST, 130004, "독후감 생성을 위해서는 최소 2개의 기록이 필요합니다."), /** * 140000 : roomParticipant error @@ -243,6 +245,13 @@ public enum ErrorCode implements ResponseCode { */ INVALID_FE_PLATFORM(HttpStatus.BAD_REQUEST, 300000, "유효하지 않은 FE 플랫폼입니다."), + + /** + * 310000 : gemini error + */ + GEMINI_API_REQUEST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 310000,"Gemini API 요청에 실패하였습니다."), + GEMINI_API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 310001,"Gemini API 응답에 실패하였습니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 648c7d004..fa5b606cd 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -165,6 +165,20 @@ public enum SwaggerResponseDescription { ROOM_IS_EXPIRED, ROOM_NOT_IN_PROGRESS ))), + RECORD_AI_REVIEW_CREATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + RECORD_NOT_FOUND, + ROOM_ACCESS_FORBIDDEN, + RECORD_REVIEW_NOT_ENOUGH_RECORDS, + USER_RECORD_REVIEW_COUNT_EXCEEDS_LIMIT, + GEMINI_API_REQUEST_ERROR, + GEMINI_API_RESPONSE_ERROR + ))), + RECORD_AI_USAGE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_ACCESS_FORBIDDEN + ))), // Vote VOTE_CREATE(new LinkedHashSet<>(Set.of( diff --git a/src/main/java/konkuk/thip/config/GeminiAiConfig.java b/src/main/java/konkuk/thip/config/GeminiAiConfig.java new file mode 100644 index 000000000..5fc959915 --- /dev/null +++ b/src/main/java/konkuk/thip/config/GeminiAiConfig.java @@ -0,0 +1,14 @@ +package konkuk.thip.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GeminiAiConfig { + @Bean + public ChatClient chatClient(ChatModel geminiModel) { + return ChatClient.builder(geminiModel).build(); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java index 9e59f18ce..cfc15e1c7 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java @@ -33,6 +33,8 @@ public class RoomPostCommandController { private final AttendanceCheckCreateUseCase attendanceCheckCreateUseCase; private final AttendanceCheckDeleteUseCase attendanceCheckDeleteUseCase; + private final RecordReviewCreateUseCase recordReviewCreateUseCase; + /** * 기록 관련 */ @@ -179,4 +181,20 @@ public BaseResponse deleteAttendanceCheck( attendanceCheckDeleteUseCase.delete(userId, roomId, attendanceCheckId) )); } + + @Operation( + summary = "AI 기반 기록 독후감 생성", + description = "AI를 활용하여 사용자가 작성한 기록을 바탕으로 독후감을 생성합니다." + ) + @ExceptionDescription(RECORD_AI_REVIEW_CREATE) + @PostMapping("/rooms/{roomId}/record/ai-review") + public BaseResponse createAIRecordReview( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "독후감을 생성할 방 ID", example = "1") @PathVariable final Long roomId + ) { + return BaseResponse.ok( + RecordReviewCreateResponse.of( + recordReviewCreateUseCase.createAiRecordReview(roomId, userId) + )); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java index f15332c83..d56548360 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java @@ -6,14 +6,16 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.roompost.adapter.in.web.request.RecordAiUsageResponse; import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse; import konkuk.thip.roompost.adapter.in.web.response.RecordPinResponse; import konkuk.thip.roompost.adapter.in.web.response.RoomPostSearchResponse; import konkuk.thip.roompost.application.port.in.AttendanceCheckShowUseCase; +import konkuk.thip.roompost.application.port.in.RecordAiUsageUseCase; import konkuk.thip.roompost.application.port.in.RecordPinUseCase; import konkuk.thip.roompost.application.port.in.RoomPostSearchUseCase; -import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery; import konkuk.thip.roompost.application.port.in.dto.RoomPostSearchQuery; +import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -30,6 +32,7 @@ public class RoomPostQueryController { private final RoomPostSearchUseCase roomPostSearchUseCase; private final RecordPinUseCase recordPinUseCase; private final AttendanceCheckShowUseCase attendanceCheckShowUseCase; + private final RecordAiUsageUseCase recordAiUsageUseCase; @Operation( summary = "방의 게시글(기록, 투표) 목록 조회", @@ -100,4 +103,20 @@ public BaseResponse showDailyGreeting( @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(attendanceCheckShowUseCase.showDailyGreeting(userId, roomId, cursor)); } + + @Operation( + summary = "사용자의 AI 이용 횟수 및 기록 작성 횟수 조회", + description = "사용자의 AI 이용 횟수 및 기록 작성 횟수를 조회합니다." + ) + @ExceptionDescription(RECORD_AI_USAGE) + @GetMapping("/rooms/{roomId}/users/ai-usage") + public BaseResponse getUserAiUsageCount( + @Parameter(description = "조회할 방 ID", example = "1") @PathVariable final Long roomId, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok( + RecordAiUsageResponse.of( + recordAiUsageUseCase.getUserAiUsage(userId, roomId) + ) + ); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordAiUsageResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordAiUsageResponse.java new file mode 100644 index 000000000..1fe645062 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordAiUsageResponse.java @@ -0,0 +1,15 @@ +package konkuk.thip.roompost.adapter.in.web.request; + +import konkuk.thip.roompost.application.port.in.dto.record.RecordAiUsageResult; + +public record RecordAiUsageResponse( + Integer recordReviewCount, + Integer recordCount +) { + public static RecordAiUsageResponse of(RecordAiUsageResult result) { + return new RecordAiUsageResponse( + result.recordReviewCount(), + result.recordCount() + ); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordReviewCreateResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordReviewCreateResponse.java new file mode 100644 index 000000000..a84d14b68 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordReviewCreateResponse.java @@ -0,0 +1,12 @@ +package konkuk.thip.roompost.adapter.in.web.response; + +import konkuk.thip.roompost.application.port.in.dto.record.RecordReviewCreateResult; + +public record RecordReviewCreateResponse( + String content, + int count +) { + public static RecordReviewCreateResponse of(RecordReviewCreateResult result) { + return new RecordReviewCreateResponse(result.content(), result.reviewCount()); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordQueryPersistenceAdapter.java index 6b3e1a1f5..4c97976aa 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordQueryPersistenceAdapter.java @@ -2,9 +2,11 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.roompost.adapter.out.mapper.RecordMapper; import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; import konkuk.thip.roompost.application.port.out.RecordQueryPort; import konkuk.thip.roompost.application.port.out.dto.RoomPostQueryDto; +import konkuk.thip.roompost.domain.Record; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -15,6 +17,7 @@ public class RecordQueryPersistenceAdapter implements RecordQueryPort { private final RecordJpaRepository recordJpaRepository; + private final RecordMapper recordMapper; @Override public CursorBasedList searchMyRecords(Long roomId, Long userId, Cursor cursor) { @@ -63,4 +66,16 @@ public CursorBasedList searchGroupRecordsByComment(Long roomId return nextCursor.toEncodedString(); }); } + + @Override + public List findAllByRoomIdAndUserId(Long roomId, Long userId) { + return recordJpaRepository.findAllByRoomIdAndUserIdOrderByPageAsc(roomId, userId).stream() + .map(recordMapper::toDomainEntity) + .toList(); + } + + @Override + public Integer countAllByRoomIdAndUserId(Long roomId, Long userId) { + return recordJpaRepository.countAllByRoomIdAndUserId(roomId, userId); + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java index 9677e8263..a600d8165 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordJpaRepository.java @@ -22,4 +22,17 @@ public interface RecordJpaRepository extends JpaRepository findAllByRoomIdAndUserIdOrderByPageAsc(Long roomId, Long userId); + + @Query("SELECT COUNT(r) FROM RecordJpaEntity r " + + "WHERE r.roomJpaEntity.roomId = :roomId " + + "AND r.userJpaEntity.userId = :userId " + + "AND r.isOverview = false") + Integer countAllByRoomIdAndUserId(Long roomId, Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/RecordAiUsageUseCase.java b/src/main/java/konkuk/thip/roompost/application/port/in/RecordAiUsageUseCase.java new file mode 100644 index 000000000..a86b56afa --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/RecordAiUsageUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.roompost.application.port.in; + +import konkuk.thip.roompost.application.port.in.dto.record.RecordAiUsageResult; + +public interface RecordAiUsageUseCase { + RecordAiUsageResult getUserAiUsage(Long userId, Long roomId); +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/RecordReviewCreateUseCase.java b/src/main/java/konkuk/thip/roompost/application/port/in/RecordReviewCreateUseCase.java new file mode 100644 index 000000000..634484dda --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/RecordReviewCreateUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.roompost.application.port.in; + +import konkuk.thip.roompost.application.port.in.dto.record.RecordReviewCreateResult; + +public interface RecordReviewCreateUseCase { + + RecordReviewCreateResult createAiRecordReview(Long roomId, Long userId); +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordAiUsageResult.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordAiUsageResult.java new file mode 100644 index 000000000..6ea37589f --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordAiUsageResult.java @@ -0,0 +1,7 @@ +package konkuk.thip.roompost.application.port.in.dto.record; + +public record RecordAiUsageResult( + Integer recordReviewCount, + Integer recordCount +) { +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordReviewCreateResult.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordReviewCreateResult.java new file mode 100644 index 000000000..793db28ae --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordReviewCreateResult.java @@ -0,0 +1,7 @@ +package konkuk.thip.roompost.application.port.in.dto.record; + +public record RecordReviewCreateResult( + String content, + int reviewCount +) { +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordQueryPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordQueryPort.java index 6244f9f9e..8bd4b5724 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordQueryPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordQueryPort.java @@ -3,6 +3,9 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.roompost.application.port.out.dto.RoomPostQueryDto; +import konkuk.thip.roompost.domain.Record; + +import java.util.List; public interface RecordQueryPort { @@ -13,5 +16,9 @@ public interface RecordQueryPort { CursorBasedList searchGroupRecordsByLike(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview); CursorBasedList searchGroupRecordsByComment(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview); + + List findAllByRoomIdAndUserId(Long roomId, Long userId); + + Integer countAllByRoomIdAndUserId(Long roomId, Long userId); } diff --git a/src/main/java/konkuk/thip/roompost/application/service/RecordAiUsageService.java b/src/main/java/konkuk/thip/roompost/application/service/RecordAiUsageService.java new file mode 100644 index 000000000..f24fba672 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/service/RecordAiUsageService.java @@ -0,0 +1,28 @@ +package konkuk.thip.roompost.application.service; + +import konkuk.thip.room.application.service.validator.RoomParticipantValidator; +import konkuk.thip.roompost.application.port.in.RecordAiUsageUseCase; +import konkuk.thip.roompost.application.port.in.dto.record.RecordAiUsageResult; +import konkuk.thip.roompost.application.port.out.RecordQueryPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RecordAiUsageService implements RecordAiUsageUseCase { + + private final RecordQueryPort recordQueryPort; + private final RoomParticipantValidator roomParticipantValidator; + private final UserCommandPort userCommandPort; + + @Override + public RecordAiUsageResult getUserAiUsage(Long userId, Long roomId) { + roomParticipantValidator.validateUserIsRoomMember(roomId, userId); + + Integer recordCount = recordQueryPort.countAllByRoomIdAndUserId(roomId, userId); + Integer recordReviewCount = userCommandPort.findById(userId).getRecordReviewCount(); + + return new RecordAiUsageResult(recordReviewCount, recordCount); + } +} diff --git a/src/main/java/konkuk/thip/roompost/application/service/RecordReviewCreateService.java b/src/main/java/konkuk/thip/roompost/application/service/RecordReviewCreateService.java new file mode 100644 index 000000000..7e4042662 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/service/RecordReviewCreateService.java @@ -0,0 +1,59 @@ +package konkuk.thip.roompost.application.service; + +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.ai.application.out.GeminiLoadPort; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.application.service.validator.RoomParticipantValidator; +import konkuk.thip.roompost.application.port.in.RecordReviewCreateUseCase; +import konkuk.thip.roompost.application.port.in.dto.record.RecordReviewCreateResult; +import konkuk.thip.roompost.application.port.out.RecordQueryPort; +import konkuk.thip.roompost.domain.Record; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RecordReviewCreateService implements RecordReviewCreateUseCase { + + private final RoomParticipantValidator roomParticipantValidator; + private final RecordQueryPort recordQueryPort; + private final UserCommandPort userCommandPort; + private final GeminiLoadPort geminiQueryPort; + private final BookCommandPort bookCommandPort; + + private final static int MIN_REVIEW_LENGTH = 600; // 독후감 최소 길이 + private final static int MAX_REVIEW_LENGTH = 900; // 독후감 최대 길이 + + @Override + public RecordReviewCreateResult createAiRecordReview(Long roomId, Long userId) { + roomParticipantValidator.validateUserIsRoomMember(roomId, userId); + + // 1. 필요한 엔티티 조회 + List records = recordQueryPort.findAllByRoomIdAndUserId(roomId, userId); + User user = userCommandPort.findById(userId); + Book book = bookCommandPort.findBookByRoomId(roomId); + + // 2. 유저가 독후감 생성이 가능한 상태인지 유효성 검증 (기록 개수는 2개 이상 + if(records.size() < 2) { + throw new BusinessException(ErrorCode.RECORD_REVIEW_NOT_ENOUGH_RECORDS, + new IllegalArgumentException("현재 기록 개수: " + records.size())); + } + + // 3. 독후감 생성 횟수 증가 (독후감 생성 횟수는 5회 이하일 경우만) + user.increaseRecordReviewCount(); + + // 4. 독후감 생성 + String reviewContent = geminiQueryPort.generateRecordReview(user, records, book, MIN_REVIEW_LENGTH, MAX_REVIEW_LENGTH); + + // 5. 독후감 생성 횟수 갱신 + userCommandPort.update(user); + + return new RecordReviewCreateResult(reviewContent, user.getRecordReviewCount()); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java index 5fbb26544..d2643f24f 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserJpaEntity.java @@ -1,6 +1,7 @@ package konkuk.thip.user.adapter.out.jpa; +import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; import konkuk.thip.common.exception.InvalidStateException; @@ -46,6 +47,10 @@ public class UserJpaEntity extends BaseJpaEntity { @Builder.Default private Integer followerCount = 0; // 팔로워 수 + @Builder.Default + @Column(name = "record_review_count", nullable = false) + private Integer recordReviewCount = 0; + @Enumerated(EnumType.STRING) @Column(nullable = false) private UserRole role; @@ -59,6 +64,7 @@ public void updateIncludeAliasFrom(User user) { this.nicknameUpdatedAt = user.getNicknameUpdatedAt(); this.role = UserRole.from(user.getUserRole()); this.followerCount = user.getFollowerCount(); + this.recordReviewCount = user.getRecordReviewCount(); this.alias = user.getAlias(); } @@ -67,6 +73,7 @@ public void updateFrom(User user) { this.nicknameUpdatedAt = user.getNicknameUpdatedAt(); this.role = UserRole.from(user.getUserRole()); this.followerCount = user.getFollowerCount(); + this.recordReviewCount = user.getRecordReviewCount(); } public void softDelete(User user) { @@ -77,4 +84,9 @@ public void softDelete(User user) { this.oauth2Id = user.getOauth2Id(); } + @VisibleForTesting + public void setRecordReviewCount(int count) { + this.recordReviewCount = count; + } + } diff --git a/src/main/java/konkuk/thip/user/adapter/out/mapper/UserMapper.java b/src/main/java/konkuk/thip/user/adapter/out/mapper/UserMapper.java index bb479a7b3..9e7c3abad 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/mapper/UserMapper.java +++ b/src/main/java/konkuk/thip/user/adapter/out/mapper/UserMapper.java @@ -15,6 +15,7 @@ public UserJpaEntity toJpaEntity(User user) { .role(UserRole.from(user.getUserRole())) .oauth2Id(user.getOauth2Id()) .followerCount(user.getFollowerCount()) + .recordReviewCount(user.getRecordReviewCount()) .alias(user.getAlias()) .build(); } @@ -27,6 +28,7 @@ public User toDomainEntity(UserJpaEntity userJpaEntity) { .userRole(userJpaEntity.getRole().getType()) .oauth2Id(userJpaEntity.getOauth2Id()) .followerCount(userJpaEntity.getFollowerCount()) + .recordReviewCount(userJpaEntity.getRecordReviewCount()) .alias(userJpaEntity.getAlias()) .createdAt(userJpaEntity.getCreatedAt()) .modifiedAt(userJpaEntity.getModifiedAt()) diff --git a/src/main/java/konkuk/thip/user/domain/User.java b/src/main/java/konkuk/thip/user/domain/User.java index e9115062a..439d674b7 100644 --- a/src/main/java/konkuk/thip/user/domain/User.java +++ b/src/main/java/konkuk/thip/user/domain/User.java @@ -29,6 +29,8 @@ public class User extends BaseDomainEntity { private Integer followerCount; // 팔로워 수 + private Integer recordReviewCount; // 생성한 독후감 수 + private Alias alias; public static User withoutId(String nickname, String userRole, String oauth2Id, Alias alias) { @@ -39,6 +41,7 @@ public static User withoutId(String nickname, String userRole, String oauth2Id, .userRole(userRole) .oauth2Id(oauth2Id) .followerCount(0) + .recordReviewCount(0) .alias(alias) .build(); } @@ -89,4 +92,11 @@ public void markAsDeleted() { this.oauth2Id = "deleted:" + this.oauth2Id; } + public void increaseRecordReviewCount() { + if(this.recordReviewCount >= 5) { + throw new InvalidStateException(ErrorCode.USER_RECORD_REVIEW_COUNT_EXCEEDS_LIMIT); + } + this.recordReviewCount++; + } + } diff --git a/src/main/resources/db/migration/V251012__Add_record_review_count.sql b/src/main/resources/db/migration/V251012__Add_record_review_count.sql new file mode 100644 index 000000000..b51fed1cc --- /dev/null +++ b/src/main/resources/db/migration/V251012__Add_record_review_count.sql @@ -0,0 +1,6 @@ +ALTER TABLE users + ADD COLUMN record_review_count INT NOT NULL DEFAULT 0 AFTER follower_count; + +UPDATE users +SET record_review_count = 0 +WHERE record_review_count IS NULL; \ No newline at end of file diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiReviewCreateApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiReviewCreateApiTest.java new file mode 100644 index 000000000..807e9244a --- /dev/null +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiReviewCreateApiTest.java @@ -0,0 +1,181 @@ +package konkuk.thip.roompost.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.ai.application.out.GeminiLoadPort; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.room.domain.value.RoomParticipantRole; +import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] AI 기반 기록 독후감 생성 API 테스트") +class RecordAiReviewCreateApiTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @Autowired UserJpaRepository userJpaRepository; + @Autowired BookJpaRepository bookJpaRepository; + @Autowired RoomJpaRepository roomJpaRepository; + @Autowired RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired RecordJpaRepository recordJpaRepository; + + @MockitoBean + GeminiLoadPort geminiLoadPort; + + private UserJpaEntity user; + private RoomJpaEntity room; + + private void saveUserAndRoom() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + RoomParticipantJpaEntity participant = RoomParticipantJpaEntity.builder() + .currentPage(10) + .userPercentage(80.0) + .roomParticipantRole(RoomParticipantRole.HOST) + .userJpaEntity(user) + .roomJpaEntity(room) + .build(); + + roomParticipantJpaRepository.save(participant); + } + + private RecordJpaEntity buildRecord(boolean isOverview, int page, String content) { + return RecordJpaEntity.builder() + .content(content) + .userJpaEntity(user) + .page(page) + .isOverview(isOverview) + .commentCount(0) + .likeCount(0) + .roomJpaEntity(room) + .build(); + } + + @Test + @DisplayName("기록이 2개 이상이면 AI 독후감을 생성하고, 생성 횟수를 1 증가시켜 응답한다.") + void create_ai_review_success() throws Exception { + // given + saveUserAndRoom(); + + recordJpaRepository.save(buildRecord(false, 5, "내용-1")); + recordJpaRepository.save(buildRecord(false, 7, "내용-2")); + recordJpaRepository.save(buildRecord(true, 9, "총평")); + + // LLM 호출 모킹 + final String AI_CONTENT = "AI가 생성한 독후감 본문"; + given(geminiLoadPort.generateRecordReview( + any(), ArgumentMatchers.any(), any(), anyInt(), anyInt() + )).willReturn(AI_CONTENT); + + // when + ResultActions result = mockMvc.perform( + post("/rooms/{roomId}/record/ai-review", room.getRoomId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.content").value(AI_CONTENT)) + .andExpect(jsonPath("$.data.count").isNumber()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + int returnedCount = root.path("data").path("count").asInt(); + + UserJpaEntity persisted = userJpaRepository.findById(user.getUserId()).orElseThrow(); + assertThat(returnedCount).isEqualTo(persisted.getRecordReviewCount()); + assertThat(persisted.getRecordReviewCount()).isEqualTo(1); + } + + @Test + @DisplayName("기록이 2개 미만이면 에러 코드를 반환한다.") + void create_ai_review_fail_when_not_enough_records() throws Exception { + // given + saveUserAndRoom(); + + recordJpaRepository.save(buildRecord(false, 3, "기록")); + + given(geminiLoadPort.generateRecordReview(any(), anyList(), any(), anyInt(), anyInt())) + .willReturn("호출되지 않음"); + + // when + ResultActions result = mockMvc.perform( + post("/rooms/{roomId}/record/ai-review", room.getRoomId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.RECORD_REVIEW_NOT_ENOUGH_RECORDS.getCode())); + } + + @Test + @DisplayName("독후감 생성 횟수가 5회를 초과하면 에러 코드를 반환한다.") + void create_ai_review_fail_when_exceed_max_count() throws Exception { + // given + saveUserAndRoom(); + user.setRecordReviewCount(6); + userJpaRepository.save(user); + + recordJpaRepository.save(buildRecord(false, 3, "기록1")); + recordJpaRepository.save(buildRecord(false, 3, "기록2")); + recordJpaRepository.save(buildRecord(false, 3, "기록3")); + + given(geminiLoadPort.generateRecordReview(any(), anyList(), any(), anyInt(), anyInt())) + .willReturn("호출되지 않음"); + + // when + ResultActions result = mockMvc.perform( + post("/rooms/{roomId}/record/ai-review", room.getRoomId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_RECORD_REVIEW_COUNT_EXCEEDS_LIMIT.getCode())); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiUsageApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiUsageApiTest.java new file mode 100644 index 000000000..2a16e88a2 --- /dev/null +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordAiUsageApiTest.java @@ -0,0 +1,140 @@ +package konkuk.thip.roompost.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.RoomParticipantRole; +import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 사용자의 AI 이용 횟수 및 기록 작성 횟수 조회 API 테스트") +class RecordAiUsageApiTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @Autowired UserJpaRepository userJpaRepository; + @Autowired BookJpaRepository bookJpaRepository; + @Autowired RoomJpaRepository roomJpaRepository; + @Autowired RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired RecordJpaRepository recordJpaRepository; + + private UserJpaEntity user; + private RoomJpaEntity room; + + private void saveUserAndRoom() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + RoomParticipantJpaEntity participant = RoomParticipantJpaEntity.builder() + .currentPage(10) + .userPercentage(80.0) + .roomParticipantRole(RoomParticipantRole.HOST) + .userJpaEntity(user) + .roomJpaEntity(room) + .build(); + + roomParticipantJpaRepository.save(participant); + } + + private RecordJpaEntity buildRecord(boolean isOverview, int page, String content) { + return RecordJpaEntity.builder() + .content(content) + .userJpaEntity(user) + .page(page) + .isOverview(isOverview) + .commentCount(0) + .likeCount(0) + .roomJpaEntity(room) + .build(); + } + + @Test + @DisplayName("방 참여자의 일반 기록 개수(isOverview=false)와 사용자 AI 이용 횟수를 조회한다.") + void get_user_ai_usage_success() throws Exception { + // given + saveUserAndRoom(); + + // 일반 기록 3건(isOverview=false) + recordJpaRepository.save(buildRecord(false, 5, "기록-1")); + recordJpaRepository.save(buildRecord(false, 6, "기록-2")); + recordJpaRepository.save(buildRecord(false, 7, "기록-3")); + + // 총평 기록 2건(isOverview=true) - 집계 제외 대상 + recordJpaRepository.save(buildRecord(true, 8, "총평-1")); + recordJpaRepository.save(buildRecord(true, 9, "총평-2")); + + // when + ResultActions result = mockMvc.perform( + get("/rooms/{roomId}/users/ai-usage", room.getRoomId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.recordCount").value(3)) + .andExpect(jsonPath("$.data.recordReviewCount").isNumber()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + int aiUsageCount = root.path("data").path("recordReviewCount").asInt(); + + UserJpaEntity persisted = userJpaRepository.findById(user.getUserId()).orElseThrow(); + assertThat(aiUsageCount).isEqualTo(persisted.getRecordReviewCount()); + } + + @Test + @DisplayName("총평 기록(isOverview=true)은 기록 개수 집계에서 제외된다.") + void overview_records_are_excluded_from_count() throws Exception { + // given + saveUserAndRoom(); + + // isOverview=true 만 저장 (집계 기대값: 0) + recordJpaRepository.save(buildRecord(true, 11, "총평-전용")); + + // when + ResultActions result = mockMvc.perform( + get("/rooms/{roomId}/users/ai-usage", room.getRoomId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recordCount").value(0)); + } +} \ No newline at end of file