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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +102 to +104
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Oct 13, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

잘못된 Spring AI 스타터 선택으로 컴파일/런타임 실패 가능

구글 Gemini 연동인데 spring-ai-openai-spring-boot-starter를 추가하면 OpenAI 전용 오토컨피그만 제공되어, GeminiAiConfig에서 필요한 org.springframework.ai.googleai.gemini 관련 빈을 못 찾아 컴파일이 깨지거나 런타임에 빈 생성이 실패합니다. Google Gemini용 스타터로 교체해야 합니다.

-	implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
+	implementation 'org.springframework.ai:spring-ai-google-ai-gemini-spring-boot-starter'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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'
// Spring AI - Google AI(Gemini) 연동
implementation platform("org.springframework.ai:spring-ai-bom:1.0.0-M6")
implementation 'org.springframework.ai:spring-ai-google-ai-gemini-spring-boot-starter'
🤖 Prompt for AI Agents
build.gradle lines 102-104: 현재 Google Gemini 연동인데 잘못된 OpenAI 스타터가 추가되어 있어 빈 생성
실패/컴파일 오류가 발생할 수 있으니, implementation
'org.springframework.ai:spring-ai-openai-spring-boot-starter' 를 제거하고 Google
Gemini용 스타터로 교체하십시오; 예를 들어 implementation
'org.springframework.ai:spring-ai-googleai-spring-boot-starter' (또는 프로젝트/버전 정책에
맞는 공식 Google AI 스타터 아티팩트와 버전)를 사용해 Gemini 관련
빈(org.springframework.ai.googleai.gemini 등)이 올바르게 로드되도록 수정하세요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

google ai로 하면 빈 등록이 안되어서 openai 의존성을 주입하고 내부 모델로 google ai의 gemini 모델을 사용한거야.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🌐 Web query:

Spring AI 1.0.0-M6 Google Gemini integration setup bean registration

💡 Result:

Short answer — in Spring AI 1.0.0-M6 you register Gemini (Vertex AI) clients and functions the same way you register other Spring AI beans: rely on the VertexAiGemini auto-configuration and expose beans for the ChatClient and any function callbacks.

Key steps (minimal example)

  • Ensure Vertex/Google credentials are available (ADC / service account) and Spring AI Vertex auto-config is on.
  • Expose a ChatClient bean using the provided ChatClient.Builder:
    • Example:
      • @configuration
        public class ChatClientConfig {
        @bean
        public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
        }
        }
  • Register callable functions (for Gemini function-calling) as normal Spring beans:
    • Option A — simple POJO/Function bean:
      • @bean
        public Function<MyRequest,MyResponse> currentWeather() { ... }
    • Option B — explicit FunctionCallback wrapper (required when you need OpenAPI schema / descriptions):
      • @bean
        public FunctionCallback weatherFunctionInfo() {
        return FunctionCallback.builder()
        .function("CurrentWeather", new MyWeatherService())
        .description("Get the current weather")
        .schemaType(SchemaType.OPEN_API_SCHEMA) // IMPORTANT for Gemini function calling
        .inputType(MyWeatherService.Request.class)
        .build();
        }
  • If you want ChatClient preconfigured with registered tools, inject ToolCallbackProvider into your chatClient bean and call defaultTools(tools) before build() (M6 docs show this pattern).
  • Note: M6’s “Function Calling” docs are marked deprecated in later releases; later milestones (M7+) changed tool/callback wiring (see migration notes) — if you upgrade you may need to enable tool callback support via properties or follow the newer Tool Calling APIs.

References

  • VertexAi / Gemini function registration and bean examples (Function bean and FunctionCallback wrapper). [1]
  • General M6 function-calling docs + examples for registering Function beans and ChatClient usage. [2]
  • Community tutorial showing ChatClient bean + ToolCallbackProvider usage and notes about changes in later milestones. [3]

If you want, I can paste a ready-to-run small code example (pom/deps + config + sample function) tailored to M6.


🌐 Web query:

Spring AI 1.0.0-M6 spring-ai-google-ai-gemini-spring-boot-starter artifact maven

💡 Result:

Maven coordinates (Spring AI 1.0.0‑M6):

  • GroupId: org.springframework.ai
  • ArtifactId: spring-ai-vertex-ai-gemini-spring-boot-starter
  • Version: 1.0.0-M6

Maven dependency:

org.springframework.ai
spring-ai-vertex-ai-gemini-spring-boot-starter
1.0.0-M6

Sources: Maven Central index and mvnrepository listing for the 1.0.0‑M6 starter. [1][2]

References
[1] Maven Central index for org.springframework.ai/spring-ai-vertex-ai-gemini-spring-boot-starter (1.0.0‑M6). (repo1.maven.org)
[2] mvnrepository entry for spring-ai-vertex-ai-gemini-spring-boot-starter 1.0.0‑M6. (mvnrepository.com)

Citations:


🌐 Web query:

Spring AI Google AI Gemini API spring-boot-starter artifact not Vertex AI 1.0.0-M6

💡 Result:

Short answer: Spring AI doesn't provide a standalone "Google AI Gemini" starter separate from Vertex AI in 1.0.0‑M6. The provided starters target Gemini via Vertex AI — use the Vertex/Gemini starter coordinates.

Maven (example):

  • org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter:1.0.0-M6. [1][2]

Alternate/newer autoconfiguration name shown in docs:

  • org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini (documented as the model starter). [3]

Sources:
[1] mvnrepository listing for spring-ai-vertex-ai-gemini-spring-boot-starter 1.0.0‑M6.
[2] Spring AI 1.0.0‑M6 release notes.
[3] Spring AI reference (VertexAI Gemini Chat / starter naming).


🌐 Web query:

Spring AI ChatClient OpenAI starter with Google Gemini model configuration baseUrl

💡 Result:

You can use the Spring AI OpenAI starter but point its OpenAI base-url at Vertex AI’s OpenAI-compatible endpoint and set the Gemini model name. Also supply a Google Cloud access token (refreshable) as the OpenAI api-key. Example application.yml:

Notes:

  • The Google Cloud access token is used in place of an OpenAI API key and must be refreshed (tokens typically expire ~1 hour). [1]
  • The base-url above matches Google’s OpenAI-compatible endpoint example; choose the Gemini model name supported by your project/location. [1]
  • Spring AI’s OpenAI starter supports overriding base-url and chat options via spring.ai.openai.* properties. [2]

References:
[1] Google Cloud — OpenAI compatibility / Vertex AI (examples showing base_url and use of Google credentials).
[2] Spring AI — OpenAI Chat configuration properties (spring.ai.openai.base-url, chat options).


🏁 Script executed:

#!/bin/bash
# Find Spring AI configuration in application properties/yml files
echo "=== Checking application configuration files ==="
fd -e yml -e yaml -e properties . src/main/resources src/test/resources 2>/dev/null | while read f; do
  echo "--- File: $f ---"
  cat "$f"
  echo ""
done

echo ""
echo "=== Searching for ChatModel bean definitions ==="
rg -n "ChatModel|GoogleAi|VertexAi|GeminiChatModel" --type java -C 3

echo ""
echo "=== Checking GeminiAiConfig implementation ==="
cat src/main/java/konkuk/thip/config/GeminiAiConfig.java

Length of output: 1773


🏁 Script executed:

#!/bin/bash
# Search for application.yml or application.properties with AI configuration
echo "=== Looking for Spring AI properties configuration ==="
rg -n "spring\.ai\.|openai|gemini|google" --type yaml --type properties src/main src/test 2>/dev/null || echo "No properties found with rg"

echo ""
echo "=== Checking if application.yml exists in main resources ==="
if [ -f "src/main/resources/application.yml" ]; then
  cat src/main/resources/application.yml
else
  echo "application.yml not found"
fi

echo ""
echo "=== Checking if application.properties exists ==="
if [ -f "src/main/resources/application.properties" ]; then
  cat src/main/resources/application.properties
else
  echo "application.properties not found"
fi

echo ""
echo "=== Searching for any yml/properties files in resources ==="
find src/main/resources -name "*.yml" -o -name "*.properties" 2>/dev/null | head -10

Length of output: 315


🏁 Script executed:

#!/bin/bash
# Check if there are environment-specific configuration files or external config
echo "=== Searching for profile-specific or external configuration ==="
find . -name "application-*.yml" -o -name "application-*.properties" 2>/dev/null | head -10

echo ""
echo "=== Checking docker-compose or deployment configs for AI configuration ==="
fd -e yml -e yaml docker-compose . 2>/dev/null | xargs grep -l "OPENAI\|GEMINI\|GOOGLE_AI" 2>/dev/null || echo "No docker-compose AI config found"

echo ""
echo "=== Checking if Spring AI OpenAI autoconfiguration classes are available ==="
echo "Checking what ChatModel implementation OpenAI starter provides..."
rg -n "OpenAiChatModel|ChatModel" build.gradle

echo ""
echo "=== Verifying the actual Spring AI OpenAI starter artifact name ==="
grep -A2 -B2 "spring-ai-openai" build.gradle

Length of output: 579


🌐 Web query:

Spring AI 1.0.0-M6 OpenAI starter autoconfiguration ChatModel bean registration

💡 Result:

Short answer — in Spring AI 1.0.0‑M6 the OpenAI starter auto‑configures a ChatModel bean named by the factory method openAiChatModel. The registration details:

  • Auto‑configuration class: org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration. [1]
  • Bean factory method: public OpenAiChatModel openAiChatModel(...) — the method is annotated @bean and @ConditionalOnMissingBean, so it only creates a bean when no other ChatModel (or compatible bean) is defined. [1][2]
  • Enable/disable toggle: controlled by property spring.ai.openai.chat.enabled (havingValue="true", matchIfMissing=true) — enabled by default. [1]
  • Implementation type: OpenAiChatModel implements ChatModel/StreamingChatModel. [2]

If you want to override it, define your own ChatModel/OpenAiChatModel bean or set spring.ai.openai.chat.enabled=false. References: Spring AI 1.0.0‑M6 API docs for OpenAiAutoConfiguration and OpenAiChatModel. [1][2]

Sources:
[1] Spring AI 1.0.0‑M6 — OpenAiAutoConfiguration (api docs).
[2] Spring AI 1.0.0‑M6 — OpenAiChatModel (api docs).


@buzz0331, 설명 감사합니다! 이해했습니다.

OpenAI 스타터를 사용하면서 내부적으로 Google Gemini 모델을 사용하시는 방식이군요. 다만, 현재 코드베이스에서는 Spring AI 관련 설정 파일(application.yml/application.properties)이 보이지 않는데, 외부에서 환경 변수나 설정을 통해 다음과 같이 구성하고 계신 건가요?

  • spring.ai.openai.base-url: Google의 OpenAI 호환 엔드포인트로 설정
  • spring.ai.openai.api-key: Google Cloud 액세스 토큰
  • spring.ai.openai.chat.options.model: google/gemini-2.0-flash-001 등 Gemini 모델명

이 설정들이 환경 변수나 Kubernetes Secrets 등으로 주입되고 있다면 현재 방식이 동작할 것입니다.

만약 이러한 외부 설정이 아직 구성되지 않은 상태라면, OpenAiChatModel이 기본적으로 OpenAI의 공식 API(api.openai.com)로 요청을 보내게 되어 Gemini 연동이 실제로는 작동하지 않을 수 있습니다. 현재 설정 상태를 확인해 주실 수 있을까요?


✏️ Learnings added
Learnt from: buzz0331
PR: THIP-TextHip/THIP-Server#323
File: build.gradle:102-104
Timestamp: 2025-10-13T08:39:43.808Z
Learning: Spring AI 1.0.0-M6에서 Google AI Gemini 전용 스타터가 빈 등록에 실패하는 경우, spring-ai-openai-spring-boot-starter를 사용하고 외부 설정(환경 변수 등)으로 spring.ai.openai.base-url을 Google의 OpenAI 호환 엔드포인트로, spring.ai.openai.api-key를 Google Cloud 액세스 토큰으로, spring.ai.openai.chat.options.model을 Gemini 모델명으로 지정하여 우회할 수 있습니다.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ㅇㅇ 너말대로 yml 파일을 secret으로 관리하고 있어.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

}

def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile
Expand Down
94 changes: 94 additions & 0 deletions src/main/java/konkuk/thip/common/ai/adapter/out/GeminiAdapter.java
Original file line number Diff line number Diff line change
@@ -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}

작성 지시:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

옼ㅋㅋㅋ 좋네요 이거 현준띠가 직접 짜신건가여

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ai 도움을 좀 받았습니다 ㅎㅎ

1) 서론-본론-결론 3단 구성으로 작성합니다.
2) 서론: 책 설명을 바탕으로 이번 독서의 관점/목표를 간단히 밝힙니다.
3) 본론: 기록에서 드러난 핵심 주제 2~3가지를 선택해 근거와 함께 설명합니다. 필요한 곳에 (p.xx) 표기.
4) 결론: 독서 전후의 생각 변화/적용 계획을 간단히 정리합니다.
5) 전체 분량은 대략 {minLen}~{maxLen}자 수준으로 맞춥니다.

이제 위 정보를 바탕으로 독서 감상문을 작성하세요.
""";

@Override
public String generateRecordReview(User user, List<Record> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Record> records, Book book, int minLength, int maxLength);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/konkuk/thip/config/GeminiAiConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class RoomPostCommandController {
private final AttendanceCheckCreateUseCase attendanceCheckCreateUseCase;
private final AttendanceCheckDeleteUseCase attendanceCheckDeleteUseCase;

private final RecordReviewCreateUseCase recordReviewCreateUseCase;

/**
* 기록 관련
*/
Expand Down Expand Up @@ -179,4 +181,20 @@ public BaseResponse<AttendanceCheckDeleteResponse> deleteAttendanceCheck(
attendanceCheckDeleteUseCase.delete(userId, roomId, attendanceCheckId)
));
}

@Operation(
summary = "AI 기반 기록 독후감 생성",
description = "AI를 활용하여 사용자가 작성한 기록을 바탕으로 독후감을 생성합니다."
)
@ExceptionDescription(RECORD_AI_REVIEW_CREATE)
@PostMapping("/rooms/{roomId}/record/ai-review")
public BaseResponse<RecordReviewCreateResponse> 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)
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "방의 게시글(기록, 투표) 목록 조회",
Expand Down Expand Up @@ -100,4 +103,20 @@ public BaseResponse<AttendanceCheckShowResponse> 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<RecordAiUsageResponse> 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)
)
);
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,6 +17,7 @@
public class RecordQueryPersistenceAdapter implements RecordQueryPort {

private final RecordJpaRepository recordJpaRepository;
private final RecordMapper recordMapper;

@Override
public CursorBasedList<RoomPostQueryDto> searchMyRecords(Long roomId, Long userId, Cursor cursor) {
Expand Down Expand Up @@ -63,4 +66,16 @@ public CursorBasedList<RoomPostQueryDto> searchGroupRecordsByComment(Long roomId
return nextCursor.toEncodedString();
});
}

@Override
public List<Record> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,17 @@ public interface RecordJpaRepository extends JpaRepository<RecordJpaEntity, Long
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE RecordJpaEntity r SET r.status = 'INACTIVE' WHERE r.userJpaEntity.userId = :userId")
void softDeleteAllByUserId(@Param("userId") Long userId);

@Query("SELECT r FROM RecordJpaEntity r " +
"WHERE r.roomJpaEntity.roomId = :roomId " +
"AND r.userJpaEntity.userId = :userId " +
"AND r.isOverview = false " +
"ORDER BY r.page ASC")
List<RecordJpaEntity> 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);
Comment on lines +26 to +37
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

확인했습니다

}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package konkuk.thip.roompost.application.port.in.dto.record;

public record RecordAiUsageResult(
Integer recordReviewCount,
Integer recordCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package konkuk.thip.roompost.application.port.in.dto.record;

public record RecordReviewCreateResult(
String content,
int reviewCount
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -13,5 +16,9 @@ public interface RecordQueryPort {
CursorBasedList<RoomPostQueryDto> searchGroupRecordsByLike(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview);

CursorBasedList<RoomPostQueryDto> searchGroupRecordsByComment(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview);

List<Record> findAllByRoomIdAndUserId(Long roomId, Long userId);

Integer countAllByRoomIdAndUserId(Long roomId, Long userId);
}

Loading