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
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ dependencies {
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
runtimeOnly "io.netty:netty-resolver-dns-native-macos:4.1.110.Final:osx-aarch_64"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public static class QuestionDto {
@JsonInclude(Include.NON_NULL)
public static class GptQuestion {
private String question;
private String topic;
private String keyword;
}

@Getter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.project.InsightPrep.domain.question.entity;

public enum ItemType {
TOPIC, KEYWORD
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.project.InsightPrep.domain.question.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(
name = "recent_prompt_filters",
indexes = {
@Index(name = "idx_recent_10", columnList = "member_id, category, item_type, created_at")
},
uniqueConstraints = {
@UniqueConstraint(
name = "uq_user_cat_type_value",
columnNames = {"member_id", "category", "item_type", "item_value"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class RecentPromptFilter {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "member_id", nullable = false)
private Long memberId;

@Column(nullable = false, length = 50)
private String category;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ItemType itemType; // 주제 / 키워드

@Column(nullable = false, length = 200)
private String itemValue;

@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.project.InsightPrep.domain.question.mapper;

import com.project.InsightPrep.domain.question.entity.ItemType;
import com.project.InsightPrep.domain.question.entity.RecentPromptFilter;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface RecentPromptFilterMapper {
void insert(RecentPromptFilter recentPromptFilter);

List<String> findTopNByUserCategoryType(
@Param("memberId") long memberId,
@Param("category") String category,
@Param("type")ItemType type,
@Param("limit") int limit);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.project.InsightPrep.domain.question.service;

import com.project.InsightPrep.domain.question.entity.ItemType;
import java.util.List;

public interface RecentPromptFilterService {

void record(long memberId, String category, ItemType type, String value);

List<String> getRecent(long memberId, String category, ItemType type, int limit);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionDto;
import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto;
import com.project.InsightPrep.domain.question.entity.AnswerStatus;
import com.project.InsightPrep.domain.question.entity.ItemType;
import com.project.InsightPrep.domain.question.entity.Question;
import com.project.InsightPrep.domain.question.mapper.AnswerMapper;
import com.project.InsightPrep.domain.question.mapper.QuestionMapper;
import com.project.InsightPrep.domain.question.service.QuestionService;
import com.project.InsightPrep.domain.question.service.RecentPromptFilterService;
import com.project.InsightPrep.global.auth.util.SecurityUtil;
import com.project.InsightPrep.global.gpt.dto.response.GptMessage;
import com.project.InsightPrep.global.gpt.prompt.PromptFactory;
import com.project.InsightPrep.global.gpt.service.GptResponseType;
import com.project.InsightPrep.global.gpt.service.GptService;
Expand All @@ -28,13 +31,26 @@ public class QuestionServiceImpl implements QuestionService {
private final GptService gptService;
private final QuestionMapper questionMapper;
private final AnswerMapper answerMapper;
private final RecentPromptFilterService recentPromptFilterService;
private final SecurityUtil securityUtil;

@Override
@Transactional
public QuestionDto createQuestion(String category) {
GptQuestion gptQuestion = gptService.callOpenAI(PromptFactory.forQuestionGeneration(category), 1000, 0.6, GptResponseType.QUESTION);
long memberId = securityUtil.getLoginMemberId();
// 1) 최근 금지 주제/키워드 조회 (없을 수 있음)
List<String> bannedTopics = recentPromptFilterService.getRecent(memberId, category, ItemType.TOPIC, 10);
List<String> bannedKeywords = recentPromptFilterService.getRecent(memberId, category, ItemType.KEYWORD, 10);

// 2) 프롬프트 선택 (있으면 주입, 없으면 기본)
List<GptMessage> prompt = (hasAny(bannedTopics, bannedKeywords))
? PromptFactory.forQuestionGeneration(category, bannedTopics, bannedKeywords)
: PromptFactory.forQuestionGeneration(category);

// 3) 호출
GptQuestion gptQuestion = gptService.callOpenAI(prompt, 1000, 0.6, GptResponseType.QUESTION);

// 4) DB에 저장
Question question = Question.builder()
.category(category)
.content(gptQuestion.getQuestion())
Expand All @@ -43,6 +59,14 @@ public QuestionDto createQuestion(String category) {

questionMapper.insertQuestion(question);

// 5) 기록 (Redis + DB) - 응답에 topic/keyword가 비어있을 수도 있으므로 방어
if (isNotBlank(gptQuestion.getTopic())) {
recentPromptFilterService.record(memberId, category, ItemType.TOPIC, gptQuestion.getTopic());
}
if (isNotBlank(gptQuestion.getKeyword())) {
recentPromptFilterService.record(memberId, category, ItemType.KEYWORD, gptQuestion.getKeyword());
}

return QuestionResponse.QuestionDto.builder()
.id(question.getId())
.content(question.getContent())
Expand All @@ -64,4 +88,10 @@ public PageResponse<QuestionsDto> getQuestions(int page, int size) {
long total = answerMapper.countQuestionsWithFeedback(memberId);
return PageResponse.of(content, safePage, safeSize, total);
}

private boolean hasAny(List<String> a, List<String> b) {
return (a != null && !a.isEmpty()) || (b != null && !b.isEmpty());
}

private boolean isNotBlank(String s) { return s != null && !s.isBlank(); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.project.InsightPrep.domain.question.service.impl;

import com.project.InsightPrep.domain.question.entity.ItemType;
import com.project.InsightPrep.domain.question.entity.RecentPromptFilter;
import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper;
import com.project.InsightPrep.domain.question.service.RecentPromptFilterService;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
@RequiredArgsConstructor
public class RecentPromptFilterServiceImpl implements RecentPromptFilterService {

private final StringRedisTemplate redis;
private final RecentPromptFilterMapper recentMapper;
private static final String KEY_FMT = "rp:%d:%s:%s"; // memberId, category, type
public static final int MAX_SIZE = 10;
public static final Duration TTL = Duration.ofDays(14); // 만료일

@Override
@Transactional
public void record(long memberId, String category, ItemType type, String value) {
// DB 영구 저장 (unique 제약 조건으로 중복 방지)
RecentPromptFilter recentPromptFilter = RecentPromptFilter.builder()
.memberId(memberId)
.category(category)
.itemType(type)
.itemValue(value)
.build();
try {
recentMapper.insert(recentPromptFilter);
} catch (DataIntegrityViolationException ignore) {
// 유니크 제약 충돌은 무시 (이미 기록된 값)
}

// redis 캐시 (최근 10개 유지)
String key = key(memberId, category, type);
double score = System.currentTimeMillis();
redis.opsForZSet().add(key, value, score);

// 오래된 것 제거
Long size = redis.opsForZSet().size(key); // ZSet: 낮은 rank가 오래된 것
if (size != null && size > MAX_SIZE) {
redis.opsForZSet().removeRange(key, 0, size - MAX_SIZE - 1);
}

redis.expire(key, TTL); // TTL 적용
}

@Override
@Transactional(readOnly = true)
public List<String> getRecent(long memberId, String category, ItemType type, int limit) {
String key = key(memberId, category, type);

// 최신순 상위 N
Set<String> z = redis.opsForZSet().reverseRange(key, 0, Math.max(0, limit - 1));
if (z != null && !z.isEmpty()) {
return new ArrayList<>(z);
}

// 캐시 미스 → DB fallback (최근 10개)
List<String> fromDb = recentMapper.findTopNByUserCategoryType(memberId, category, type, limit);
for (int i = 0; i < fromDb.size(); i++) {
redis.opsForZSet().add(key, fromDb.get(i), System.currentTimeMillis() + i);
}
redis.expire(key, TTL);
return fromDb;
}

private String key(long userId, String category, ItemType type) {
return String.format(KEY_FMT, userId, category, type.name());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class CorsConfig {
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:8080"));
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS", "PATCH"));
cfg.setAllowedHeaders(List.of("*"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.project.InsightPrep.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisConfig {

@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class WebConfig implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080", "http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true); // 세션 쿠키 사용할 경우 true
}
Expand Down
Loading