diff --git a/src/main/java/swyp_11/ssubom/domain/topic/controller/AdminController.java b/src/main/java/swyp_11/ssubom/domain/topic/controller/AdminController.java new file mode 100644 index 0000000..b72972a --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/controller/AdminController.java @@ -0,0 +1,80 @@ +package swyp_11.ssubom.domain.topic.controller; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import swyp_11.ssubom.domain.topic.dto.*; +import swyp_11.ssubom.domain.topic.entity.Status; +import swyp_11.ssubom.domain.topic.entity.Topic; +import swyp_11.ssubom.domain.topic.service.TopicAIService; +import swyp_11.ssubom.domain.topic.service.TopicGenerationService; +import swyp_11.ssubom.domain.topic.service.TopicService; +import swyp_11.ssubom.global.response.ApiResponse; + +import java.time.LocalDate; +import java.util.List; + +@Tag(name = "Admin 페이지전용 ", description = " topic 관련 admin API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin") +public class AdminController { + private final TopicService topicService; + private final TopicGenerationService topicGenerationService; + + @PostMapping("/topic/generation") + public ApiResponse topicGeneration(){ + topicGenerationService.generateTopics(); + return ApiResponse.success(null,"AD0001","관리자 질문 자동 생성 성공"); + } + + @PostMapping("/topic/generation/{categoryId}") + public ApiResponse createTopic(@PathVariable Long categoryId, @RequestBody TopicCreationRequest request) { + Topic savedTopic = topicService.generateTopicForCategory( + categoryId, + request.getTopicName(), + request.getTopicType() + ); + TodayTopicResponseDto dto = TodayTopicResponseDto.fromTopic(savedTopic); + return ApiResponse.success(dto,"AD0002","관리자 질문 생성 성공"); + } + + @PatchMapping("/topic/{topicId}") + public ApiResponse updateTopic(@PathVariable Long topicId, @RequestBody TopicUpdateRequest request) { + Topic savedTopic = topicService.updateTopic(topicId, request); + TodayTopicResponseDto dto = TodayTopicResponseDto.fromTopic(savedTopic); + return ApiResponse.success(dto,"AD0003","관리자 질문 수정 성공"); + } + + @DeleteMapping("/topic/{topicId}") + public ApiResponse deleteTopic(@PathVariable Long topicId) { + topicService.deleteTopic(topicId); + return ApiResponse.success(null); + } + + + @GetMapping("/topics") + public ApiResponse getAdminTopics(@RequestParam(required = false ,defaultValue = "ALL") String mode , @RequestParam(required = false)Long categoryId) { + return ApiResponse.success(topicService.getAdminTopics(mode,categoryId),"AD0004","관리자 질문 조회 성공"); + + } + + @PatchMapping("/topic/{topicId}/status") + public ApiResponse updateTopicStatus(@PathVariable Long topicId, @RequestParam Status status){ + topicService.updateTopicStatus(topicId,status); + return ApiResponse.success(null,"AD0005","질문 상태 변경 성공"); + } + + @PatchMapping("/topics/{topicId}/reservation") + public ApiResponse updateReservation( + @PathVariable Long topicId, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate usedAt + ) { + topicService.updateReservation(topicId, usedAt); + return ApiResponse.success(null, "AD0006", "예약 변경 성공"); + } +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/controller/CategoryController.java b/src/main/java/swyp_11/ssubom/domain/topic/controller/CategoryController.java index 39e669c..969c38c 100644 --- a/src/main/java/swyp_11/ssubom/domain/topic/controller/CategoryController.java +++ b/src/main/java/swyp_11/ssubom/domain/topic/controller/CategoryController.java @@ -10,9 +10,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import swyp_11.ssubom.domain.topic.dto.HomeResponse; -import swyp_11.ssubom.domain.topic.dto.TodayTopicResponseDto; -import swyp_11.ssubom.domain.topic.dto.TopicListResponse; +import swyp_11.ssubom.domain.topic.dto.*; +import swyp_11.ssubom.domain.topic.entity.Topic; +import swyp_11.ssubom.domain.topic.service.TopicGenerationService; import swyp_11.ssubom.domain.topic.service.TopicService; import swyp_11.ssubom.domain.user.dto.CustomOAuth2User; import swyp_11.ssubom.global.error.BusinessException; @@ -26,7 +26,7 @@ @RequiredArgsConstructor public class CategoryController { private final TopicService topicService; - + private final TopicGenerationService topicGenerationService; @Operation( summary = "카테고리 오늘의 질문 조회 API", description = """ @@ -83,4 +83,6 @@ public ResponseEntity> getHome(@AuthenticationPrincipa ); return ResponseEntity.ok(response); } + + } diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicDto.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicDto.java new file mode 100644 index 0000000..72c08ad --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicDto.java @@ -0,0 +1,35 @@ +package swyp_11.ssubom.domain.topic.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import swyp_11.ssubom.domain.topic.entity.Status; +import swyp_11.ssubom.domain.topic.entity.Topic; +import swyp_11.ssubom.domain.topic.entity.TopicType; + +import java.time.LocalDate; + +@Getter +@Builder +@AllArgsConstructor +public class AdminTopicDto { + private Long categoryId; + private String categoryName; + private Long topicId; + private String topicName; + private TopicType topicType; + private Status topicStatus; + private LocalDate usedAt; + + public static AdminTopicDto from(Topic topic){ + return AdminTopicDto.builder() + .categoryId(topic.getCategory().getId()) + .categoryName(topic.getCategory().getName()) + .topicId(topic.getId()) + .topicName(topic.getName()) + .topicType(topic.getTopicType()) + .topicStatus(topic.getTopicStatus()) + .usedAt(topic.getUsedAt()) + .build(); + } +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicListResponse.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicListResponse.java new file mode 100644 index 0000000..f56483d --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/AdminTopicListResponse.java @@ -0,0 +1,25 @@ +package swyp_11.ssubom.domain.topic.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import swyp_11.ssubom.domain.topic.entity.Topic; +import swyp_11.ssubom.domain.topic.entity.TopicType; + +import java.util.List; + + +@Getter +@Builder +public class AdminTopicListResponse { + private long totalCount; + private List topics; + + public static AdminTopicListResponse of(List topics) { + return AdminTopicListResponse.builder() + .totalCount(topics.size()) + .topics(topics) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/EmbeddingApiResponseDto.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/EmbeddingApiResponseDto.java new file mode 100644 index 0000000..617d0cf --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/EmbeddingApiResponseDto.java @@ -0,0 +1,34 @@ +package swyp_11.ssubom.domain.topic.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +public class EmbeddingApiResponseDto { + private Status status; + private Result result; + + @Getter + @NoArgsConstructor + public static class Status { + String code; + String message; + } + + @Getter + @NoArgsConstructor + public static class Result { + private List embedding; + private Integer inputTokens; + } + + public boolean isSuccess() { + return status !=null && "20000".equals(status.getCode()); + } + + public List getVector(){ + return (result != null) ? result.getEmbedding() : null; + } +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/TodayTopicResponseDto.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/TodayTopicResponseDto.java index 6d2ae6d..7200f57 100644 --- a/src/main/java/swyp_11/ssubom/domain/topic/dto/TodayTopicResponseDto.java +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/TodayTopicResponseDto.java @@ -24,4 +24,14 @@ public static TodayTopicResponseDto of(String categoryName, String topicName,Lon .topicType(topicType) .build(); } + + public static TodayTopicResponseDto fromTopic(Topic savedTopic) { + return TodayTopicResponseDto.builder() + .categoryName(savedTopic.getCategory().getName()) + .topicName(savedTopic.getName()) + .categoryId(savedTopic.getCategory().getId()) + .topicId(savedTopic.getId()) + .topicType(savedTopic.getTopicType()) + .build(); + } } diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicCreationRequest.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicCreationRequest.java new file mode 100644 index 0000000..02e0779 --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicCreationRequest.java @@ -0,0 +1,12 @@ +package swyp_11.ssubom.domain.topic.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import swyp_11.ssubom.domain.topic.entity.TopicType; + +@AllArgsConstructor +@Getter +public class TopicCreationRequest { + String topicName; + TopicType topicType; +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicGenerationResponse.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicGenerationResponse.java new file mode 100644 index 0000000..de9de97 --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicGenerationResponse.java @@ -0,0 +1,6 @@ +package swyp_11.ssubom.domain.topic.dto; + +public record TopicGenerationResponse( + String topicName, + String topicType +){} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicUpdateRequest.java b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicUpdateRequest.java new file mode 100644 index 0000000..938d10d --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/dto/TopicUpdateRequest.java @@ -0,0 +1,16 @@ +package swyp_11.ssubom.domain.topic.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Getter; +import swyp_11.ssubom.domain.topic.entity.TopicType; + +import java.time.LocalDate; + +@AllArgsConstructor +@Getter +public class TopicUpdateRequest { // + String topicName; + TopicType topicType; + Long categoryId; +} \ No newline at end of file diff --git a/src/main/java/swyp_11/ssubom/domain/topic/entity/Status.java b/src/main/java/swyp_11/ssubom/domain/topic/entity/Status.java new file mode 100644 index 0000000..24d59dd --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/entity/Status.java @@ -0,0 +1,5 @@ +package swyp_11.ssubom.domain.topic.entity; + +public enum Status { + PENDING,APPROVED +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/entity/Topic.java b/src/main/java/swyp_11/ssubom/domain/topic/entity/Topic.java index aade903..788babb 100644 --- a/src/main/java/swyp_11/ssubom/domain/topic/entity/Topic.java +++ b/src/main/java/swyp_11/ssubom/domain/topic/entity/Topic.java @@ -7,6 +7,7 @@ import swyp_11.ssubom.domain.common.BaseTimeEntity; import java.time.LocalDate; +import java.util.List; @Getter @Entity @@ -36,8 +37,70 @@ public class Topic extends BaseTimeEntity { @Column(name = "topic_type", length = 20, nullable = false) private TopicType topicType; + @Column(name = "embedding_json", columnDefinition = "TEXT") + private String embeddingJson; + + @Enumerated(EnumType.STRING) + @Column(name = "topic_status") + private Status topicStatus; + + @Transient + private List embedding; + public void use(LocalDate today) { this.isUsed = true; this.usedAt = today; } + + public void setCategory(Category category) { + this.category = category; + } + + public void setTopicStatus(Status newStatus){ + this.topicStatus=newStatus; + } + public void updateNameAndType(String topicName, TopicType topicType ) { + if (topicName != null) { + this.name = topicName; + } + if (topicType != null) { + this.topicType = topicType; + } + } + + public void reserveAt(LocalDate date) { + this.usedAt = date; + } + + public static Topic create(Category category, String topicName,TopicType topicType,List embedding) { + Topic topic = new Topic(); + topic.category = category; + topic.name = topicName; + topic.topicType = topicType; + topic.embedding = embedding; + topic.topicStatus=Status.PENDING; + topic.embeddingJson = toJson(embedding); + topic.isUsed = false; + topic.usedAt = null; + return topic; + } + private static String toJson(List embedding) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(embedding); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public List getEmbedding() { + if (embedding == null && embeddingJson != null) { + try { + embedding = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(embeddingJson, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return embedding; + } } \ No newline at end of file diff --git a/src/main/java/swyp_11/ssubom/domain/topic/repository/TopicRepository.java b/src/main/java/swyp_11/ssubom/domain/topic/repository/TopicRepository.java index 86d80f8..e939ca4 100644 --- a/src/main/java/swyp_11/ssubom/domain/topic/repository/TopicRepository.java +++ b/src/main/java/swyp_11/ssubom/domain/topic/repository/TopicRepository.java @@ -1,11 +1,14 @@ package swyp_11.ssubom.domain.topic.repository; +import org.springframework.cglib.core.Local; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import swyp_11.ssubom.domain.topic.entity.Status; import swyp_11.ssubom.domain.topic.entity.Topic; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -13,21 +16,46 @@ public interface TopicRepository extends JpaRepository { //중복확인 Optional findByCategory_IdAndIsUsedTrueAndUsedAt(Long categoryId, LocalDate usedAt); - //사용 안된 것 하나 선택하기 + //관리자 페이지 ) 이미 할당 완료한 경우 + @Query("SELECT t FROM Topic t " + + "WHERE t.category.id = :categoryId " + + "AND t.usedAt = :usedAt " + + "AND t.topicStatus = 'APPROVED'") + Optional findReservedTopic(@Param("categoryId") Long categoryId , @Param("usedAt")LocalDate usedAt); + + //예약된 게 없을 때 사용 안된 것 하나 선택하기 @Query(value = """ select * from sseobom.topic where category_id = :categoryId and is_used = false + and topic_status='APPROVED' ORDER BY random() FOR UPDATE SKIP LOCKED LIMIT 1 """, nativeQuery = true) Topic lockOneUnused(@Param("categoryId") Long categoryId); - List findTop30ByCategoryIdAndUsedAtIsNotNullOrderByUsedAtDesc(Long categoryId); + List findTop30ByCategoryIdAndIsUsedTrueOrderByUsedAtDesc(Long categoryId); - List findTop30ByCategoryIdAndUsedAtIsNotNullOrderByUsedAtAsc(Long categoryId); + List findTop30ByCategoryIdAndIsUsedTrueOrderByUsedAtAsc(Long categoryId); Optional findByUsedAtAndCategory_Id(LocalDate usedAt, Long categoryId); + List findTop40ByCategoryIdAndTopicStatusOrderByUpdatedAtDesc(Long categoryId, Status topicStatus); + + @Query(""" + select t from Topic t + where (:categoryId is null or t.category.id=:categoryId ) + and t.isUsed=false + and(:mode='ALL' + or(:mode='APPROVED' and t.topicStatus='APPROVED') + or(:mode = 'PENDING' AND t.topicStatus = 'PENDING') + or(:mode = 'QUESTION' AND t.topicType = 'QUESTION') + or(:mode = 'LOGICAL' AND t.topicType = 'LOGICAL') + ) + order by t.id desc + """) + List findAdminTopics(@Param("mode")String mode , @Param("categoryId") Long categoryId); + + } diff --git a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicAIService.java b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicAIService.java new file mode 100644 index 0000000..08b8cda --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicAIService.java @@ -0,0 +1,182 @@ +package swyp_11.ssubom.domain.topic.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import swyp_11.ssubom.domain.post.dto.ClovaApiRequestDto; +import swyp_11.ssubom.domain.post.dto.ClovaApiResponseDto; +import swyp_11.ssubom.domain.topic.dto.EmbeddingApiResponseDto; +import swyp_11.ssubom.domain.topic.dto.TopicGenerationResponse; +import swyp_11.ssubom.global.error.BusinessException; +import swyp_11.ssubom.global.error.ErrorCode; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + + + +@Slf4j +@Service +@RequiredArgsConstructor +public class TopicAIService { + + @Qualifier("clovaWebClient") + private final WebClient clovaWebClient; + private final ObjectMapper objectMapper; + + @Value("classpath:/static/topic/topic-system.txt") + private Resource topicSystem; + + @Value("classpath:/static/topic/topic-schema.json") + private Resource topicSchema; + + private String systemPrompt; + private Map schema; + + @PostConstruct + public void init() throws IOException { + this.systemPrompt = new String(topicSystem.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String schemaJson = new String(topicSchema.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + this.schema = objectMapper.readValue(schemaJson, new TypeReference>() {}); + } + + public List generateTopics(String prompt){ + String userPrompt= "카테고리 주제" + prompt; + ClovaApiRequestDto requestDto = ClovaApiRequestDto.builder() + .messages(List.of( + ClovaApiRequestDto.Message.builder() + .role("system") + .content(systemPrompt) + .build(), + ClovaApiRequestDto.Message.builder() + .role("user") + .content(userPrompt) + .build() + )) + .responseFormat( + ClovaApiRequestDto.ResponseFormat.builder() + .type("json") + .schema(schema) + .build() + ) + .thinking(ClovaApiRequestDto.Thinking.builder() + .effort("none") + .build() + ) + .temperature(0.7) + .maxCompletionTokens(5000) + .topP(0.8) + .build(); + + // 요청 JSON 로깅용 + String requestJson = writeJson(requestDto); + log.info("[Topic Clova Request] {}", requestJson); + + // 2. 클로바 호출 + String rawResponse = clovaWebClient.post() + .uri("/v3/chat-completions/HCX-007") + .bodyValue(requestJson) + .retrieve() + .bodyToMono(String.class) + .block(); + + log.info("[Topic Clova Response Raw] {}", rawResponse); + ClovaApiResponseDto apiResponse = readJson(rawResponse, ClovaApiResponseDto.class); + + if (!apiResponse.isSuccess()) { + log.error("Topic Clova API 실패 code={}, message={}", + apiResponse.getStatus().getCode(), + apiResponse.getStatus().getMessage()); + throw new RuntimeException("Clova topic API failed"); + } + + + // 4. 2단계: result.message.content 에 있는 JSON 배열 → List + String llmJson = apiResponse.getLlmResultContent(); + log.info("[Topic LLM JSON Only] {}", llmJson); + + if (llmJson == null) { + throw new RuntimeException("Topic LLM content is null"); + } + + try { + return objectMapper.readValue( + llmJson, + new TypeReference>() {} + ); + } catch (IOException e) { + log.error("Topic JSON parse error. content={}", llmJson, e); + throw new RuntimeException("Topic LLM JSON parse failed", e); + } + } + + private String writeJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + throw new RuntimeException("JSON 직렬화 실패", e); + } + } + + private T readJson(String json, Class clazz) { + try { + return objectMapper.readValue(json, clazz); + } catch (Exception e) { + throw new RuntimeException("JSON 역직렬화 실패", e); + } + } + + // 다중호출 + public List getEmbedding(String text){ + // 429 TOO_MANY_REQUESTS 방지 + try { + Thread.sleep(250); // 0.25초 딜레이 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + Map request = Map.of( + "text", text); + + String raw = clovaWebClient.post() + .uri("/v1/api-tools/embedding/v2") + .bodyValue(request) + .retrieve() + .bodyToMono(String.class) + .block(); + + log.info("Embedding raw data: {}", raw); + + EmbeddingApiResponseDto response; + // wrapper 파싱 + try { + response = objectMapper.readValue(raw, EmbeddingApiResponseDto.class); + }catch (Exception e){ + throw new RuntimeException("JSON 역직렬화 실패", e); + } + + if(!response.isSuccess()){ + throw new RuntimeException("Embedding API failed: " + response.getStatus().getMessage()); + } + List vector = response.getVector(); + + if(vector.isEmpty()||vector.size()==0){ + throw new RuntimeException("Embedding vector is empty"); + } + return vector; + } + + +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicDailyPick.java b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicDailyPick.java deleted file mode 100644 index d5624f8..0000000 --- a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicDailyPick.java +++ /dev/null @@ -1,22 +0,0 @@ -package swyp_11.ssubom.domain.topic.service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import swyp_11.ssubom.domain.topic.repository.TopicRepository; - -@Slf4j -@Component -@RequiredArgsConstructor -public class TopicDailyPick { - private final TopicService topicService; - - @Scheduled(cron = "0 0 0 * * *", zone="Asia/Seoul") - public void dailyPick() { - for(Long id=1L;id<=5;id++){ - topicService.ensureTodayPicked(id); - } - log.info("오늘의 질문 할당 완료!"); - } -} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicGenerationService.java b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicGenerationService.java new file mode 100644 index 0000000..d5239c5 --- /dev/null +++ b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicGenerationService.java @@ -0,0 +1,40 @@ +package swyp_11.ssubom.domain.topic.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import swyp_11.ssubom.domain.topic.entity.Category; +import swyp_11.ssubom.domain.topic.repository.CategoryRepository; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TopicGenerationService { + private final TopicService topicService; + private final CategoryRepository categoryRepository; + + @Scheduled(cron = "0 0 0 * * *", zone="Asia/Seoul") + public void dailyPick() { + for(Long id=1L;id<=5;id++){ + topicService.ensureTodayPicked(id); + } + log.info("오늘의 질문 할당 완료!"); + } + + public void generateTopics() { + List categories = categoryRepository.findAll(); + for (Category category : categories) { + try { + log.info("카테고리 [{}] 주제 생성 시작", category.getName()); + topicService.generateTopicsForCategory(category.getId()); + log.info("카테고리 [{}] 주제 생성 완료", category.getName()); + } catch (Exception e) { + log.error(" 카테고리 [{}] 주제 생성 실패", category.getName(), e); + } + } + log.info("=== 주제 생성 스케줄러 종료 ==="); + } +} diff --git a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicService.java b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicService.java index c756386..2d1df94 100644 --- a/src/main/java/swyp_11/ssubom/domain/topic/service/TopicService.java +++ b/src/main/java/swyp_11/ssubom/domain/topic/service/TopicService.java @@ -8,7 +8,9 @@ import swyp_11.ssubom.domain.post.service.PostService; import swyp_11.ssubom.domain.topic.dto.*; import swyp_11.ssubom.domain.topic.entity.Category; +import swyp_11.ssubom.domain.topic.entity.Status; import swyp_11.ssubom.domain.topic.entity.Topic; +import swyp_11.ssubom.domain.topic.entity.TopicType; import swyp_11.ssubom.domain.topic.repository.CategoryRepository; import swyp_11.ssubom.domain.topic.repository.TopicRepository; import swyp_11.ssubom.domain.user.dto.StreakResponse; @@ -17,9 +19,10 @@ import swyp_11.ssubom.global.error.ErrorCode; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.ZoneId; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; @Slf4j @@ -33,16 +36,32 @@ public class TopicService { private final CategoryRepository categoryRepository; private final UserService userService; private final PostService postService; + private final TopicAIService topicAIService; @Transactional public Optional ensureTodayPicked(Long categoryId) { LocalDate today = LocalDate.now(KST); + + //0. 이미 오늘 할당이 완료된 경우 Optional existing = topicRepository.findByCategory_IdAndIsUsedTrueAndUsedAt(categoryId, today); if(existing.isPresent()) { return existing; } + //1순위 + Optional reserved = topicRepository.findReservedTopic(categoryId,today); + Topic targetTopic; + if(reserved.isPresent()){ + targetTopic =reserved.get(); + if(!targetTopic.isUsed()){ + targetTopic.use(today); + } //isUsed = ture처리 + return Optional.of(targetTopic); + } + + // 2순위 Topic topic =topicRepository.lockOneUnused(categoryId); - //다 씀 + + //할당할 수 있는 주제가 없음 if (topic == null) { throw new BusinessException(ErrorCode.NO_AVAILABLE_TOPIC); } @@ -66,10 +85,10 @@ public Optional ensureTodayPickedDto(Long categoryId) { public TopicListResponse getAll(Long categoryId, String sort) { List topics; if(sort.equals("latest")){ - topics = topicRepository.findTop30ByCategoryIdAndUsedAtIsNotNullOrderByUsedAtDesc(categoryId); + topics = topicRepository.findTop30ByCategoryIdAndIsUsedTrueOrderByUsedAtDesc(categoryId); } else{ - topics = topicRepository.findTop30ByCategoryIdAndUsedAtIsNotNullOrderByUsedAtAsc(categoryId); + topics = topicRepository.findTop30ByCategoryIdAndIsUsedTrueOrderByUsedAtAsc(categoryId); } List categories =categoryRepository.findAll().stream() .map(c->CategorySummaryDto.builder() @@ -91,6 +110,166 @@ public TopicListResponse getAll(Long categoryId, String sort) { return new TopicListResponse(categories,categoryName,topicCollectionResponses); } + @Transactional + public void generateTopicsForCategory(Long categoryId) { + Category category = categoryRepository.findById(categoryId) + .orElseThrow(()-> new BusinessException(ErrorCode.CATEGORY_NOT_FOUND)); + + String categoryPrompt = switch (category.getName()) { + case "일상" -> "일상: 매일 반복되는 습관, 공간, 감정의 변화를 관찰하고 의미를 찾는 주제"; + case "인간관계" -> "인간관계: 연애, 가족, 친구 사이의 심리, 소통, 갈등 해법을 탐구하는 주제 등 (예: 연인의 과거에 대한 태도 등)"; + case "문화·트렌드" -> "문화*트렌드: SNS, 소비 트렌드, 최신 라이프스타일을 해석하는 주제 등등(예: 연애 프로그램 출연 선택 등)"; + case "가치관" -> "가치관: 삶의 우선순위, 행복의 기준, 도덕적 딜레마를 다루는 주제 등"; + case "시대·사회" -> "시대와 사회: AI 기술, 세대 갈등, 환경, 공정성 등 시대 및 사회의 변화를 비판적으로 사고하는 주제 등"; + default -> throw new BusinessException(ErrorCode.CATEGORY_NOT_FOUND); + }; + List aiTopics =topicAIService.generateTopics(categoryPrompt); + + // 신규 토픽 embedding 30개 한 번에 생성 + Map> newEmbeddingCache = new HashMap<>(); + for (TopicGenerationResponse t : aiTopics) { + newEmbeddingCache.put(t.topicName(), topicAIService.getEmbedding(t.topicName())); + try { + Thread.sleep(250); // 0.25초 딜레이 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); // 인터럽트 상태 복구 + throw new RuntimeException(e); + } + } + + // 3. 중복 제거 (embedding 재사용) + List filtered = + removeDuplicates(categoryId, aiTopics, newEmbeddingCache); + + List entities = filtered.stream() + .map(t -> Topic.create( + category, + t.topicName(), + TopicType.valueOf(t.topicType().toUpperCase()), + newEmbeddingCache.get(t.topicName()) + )) + .toList(); + + topicRepository.saveAll(entities); + log.info("카테고리 [{}] 에 대해 주제 {}개 생성 및 저장 완료", category.getName(), entities.size()); + } + + @Transactional + public Topic generateTopicForCategory(Long categoryId, String topicName ,TopicType topicType) { + Category category = categoryRepository.findById(categoryId) + .orElseThrow(()-> new BusinessException(ErrorCode.CATEGORY_NOT_FOUND)); + + //1.단일 토픽 임베딩 생성 + List newEmbedding = topicAIService.getEmbedding(topicName); + + //2. 임시 Topic 객체생성 + Topic newTopic = Topic.create( + category, + topicName, + topicType, + newEmbedding + ); + + // 3. 최근 토픽 40개 가져오기 + List recentTopics = + topicRepository.findTop40ByCategoryIdAndTopicStatusOrderByUpdatedAtDesc(categoryId, Status.APPROVED); + + if(isNotDuplicate(newEmbedding, recentTopics)) { + Topic savedTopic = topicRepository.save(newTopic); + log.info("카테고리 [{}] 수동 주제 [{}] 생성 및 저장 완료", category.getName(), topicName); + return savedTopic; + }else { + // 중복일 경우 비즈니스 예외 발생 또는 null 반환 등 정책 결정 + log.warn("카테고리 [{}] 에 대해 주제 [{}]는 중복되어 저장하지 않음", category.getName(), topicName); + throw new BusinessException(ErrorCode.DUPLICATE_TOPIC_NOT_ALLOWED); + } + + } + + + public List removeDuplicates( + Long categoryId, + List newTopics, + Map> newEmbeddingCache) { + + // 최근 토픽 40개 가져오기 + List recentTopics = + topicRepository.findTop40ByCategoryIdAndTopicStatusOrderByUpdatedAtDesc(categoryId, Status.APPROVED); + + + List result = new ArrayList<>(); + + for (TopicGenerationResponse t : newTopics) { + + List newEmb = newEmbeddingCache.get(t.topicName()); + + if (isNotDuplicate(newEmb, recentTopics)) { + result.add(t); + // 중복 없으면 recentTopics에 추가 (메모리 상) + Topic fake = Topic.create(null, t.topicName(), + TopicType.valueOf(t.topicType().toUpperCase()), + newEmb); + recentTopics.add(fake); + } + else{ log.info("유사 질문 : {}", t.topicName());} + } + return result; + } + + private boolean isNotDuplicate(List newEmbedding, List recentTopics) { + + for (Topic old : recentTopics) { + List oldEmbedding = old.getEmbedding(); + double sim = cosineSimilarity(newEmbedding, oldEmbedding); + if (sim >= 0.9) { + log.info("DB에 존재한 질문 :{}", old.getName()); + return false; + } + } + return true; + } + + public double cosineSimilarity(List v1, List v2) { + double dot = 0.0; + double normA =0.0; + double normB =0.0; + + for(int i=0;i new BusinessException(ErrorCode.TOPIC_NOT_FOUND)); + if (request.getCategoryId() != null) { + Category newCategory = categoryRepository.findById(request.getCategoryId()) + .orElseThrow(() ->new BusinessException(ErrorCode.CATEGORY_NOT_FOUND)); + topic.setCategory(newCategory); + } + topic.updateNameAndType(request.getTopicName(), request.getTopicType()); + + return topic; + } + + @Transactional + public void updateReservation(Long topicId, LocalDate usedAt) { + Topic topic = topicRepository.findById(topicId) + .orElseThrow(() -> new BusinessException(ErrorCode.TOPIC_NOT_FOUND)); + // usedAt != null 예약 + // usedAt == null 예약 취소 (자동 픽 대상) + topic.reserveAt(usedAt); + } + + @Transactional + public void deleteTopic(Long topicId) { + topicRepository.deleteById(topicId); + } + public HomeResponse getHome(Long userId) { StreakResponse streakCount = null; TodayPostResponse todayPostResponse = null; @@ -106,4 +285,28 @@ public HomeResponse getHome(Long userId) { return HomeResponse.toDto(streakCount, categories, todayPostResponse); } + + //ADMIN 페이지 관련조회 + public AdminTopicListResponse getAdminTopics(String mode,Long categoryId) { + + List topics = topicRepository.findAdminTopics(mode,categoryId); + List adminTopics = topics.stream() + .map(AdminTopicDto::from) + .toList(); + + return AdminTopicListResponse.of(adminTopics); + } + + @Transactional + public void updateTopicStatus(Long topicId,Status newStatus){ + Topic topic = topicRepository.findById(topicId) + .orElseThrow(()->new BusinessException(ErrorCode.TOPIC_NOT_FOUND)); + + if (topic.getTopicStatus() == newStatus) { + return; + } + + topic.setTopicStatus(newStatus); + } + } diff --git a/src/main/java/swyp_11/ssubom/domain/user/service/CustomOauth2UserService.java b/src/main/java/swyp_11/ssubom/domain/user/service/CustomOauth2UserService.java index 50597c7..86f73ec 100644 --- a/src/main/java/swyp_11/ssubom/domain/user/service/CustomOauth2UserService.java +++ b/src/main/java/swyp_11/ssubom/domain/user/service/CustomOauth2UserService.java @@ -21,7 +21,6 @@ public class CustomOauth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; - @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { diff --git a/src/main/java/swyp_11/ssubom/global/error/ErrorCode.java b/src/main/java/swyp_11/ssubom/global/error/ErrorCode.java index 9a249bf..2805a89 100644 --- a/src/main/java/swyp_11/ssubom/global/error/ErrorCode.java +++ b/src/main/java/swyp_11/ssubom/global/error/ErrorCode.java @@ -37,7 +37,7 @@ public enum ErrorCode { CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "T001", "카테고리를 찾을 수 없습니다."), TOPIC_NOT_FOUND(HttpStatus.NOT_FOUND, "T002", "질문을 찾을 수 없습니다."), NO_AVAILABLE_TOPIC(HttpStatus.NOT_FOUND, "T003", "사용 가능한 주제가 없습니다."), - + DUPLICATE_TOPIC_NOT_ALLOWED(HttpStatus.BAD_REQUEST,"T004", "이미 비슷한 주제가 있습니다"), // Writing errors (API 명세 기반) POST_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "글을 찾을 수 없습니다."), diff --git a/src/main/java/swyp_11/ssubom/global/security/config/SecurityConfig.java b/src/main/java/swyp_11/ssubom/global/security/config/SecurityConfig.java index e9f404a..08ef4f1 100644 --- a/src/main/java/swyp_11/ssubom/global/security/config/SecurityConfig.java +++ b/src/main/java/swyp_11/ssubom/global/security/config/SecurityConfig.java @@ -7,6 +7,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -42,6 +44,13 @@ public class SecurityConfig { @Value("${cors.allowed-origins}") private String[] allowedOrigins; + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl hierarchy = new RoleHierarchyImpl(); + hierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER"); + return hierarchy; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { @@ -101,6 +110,7 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { .requestMatchers("/","/login","/join","/api/logout","/api/oauth2-jwt-header","/api/reissue","/api/categories/**","/api/home").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.GET,"/api/posts").permitAll() + .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/unregister").hasRole("USER") .requestMatchers("/api/me").hasRole("USER") .requestMatchers("/api/posts/**").hasRole("USER") diff --git a/src/main/java/swyp_11/ssubom/global/security/jwt/CustomLogoutFilter.java b/src/main/java/swyp_11/ssubom/global/security/jwt/CustomLogoutFilter.java index 7dc2fc7..096cd23 100644 --- a/src/main/java/swyp_11/ssubom/global/security/jwt/CustomLogoutFilter.java +++ b/src/main/java/swyp_11/ssubom/global/security/jwt/CustomLogoutFilter.java @@ -86,8 +86,6 @@ private void doFilter(HttpServletRequest request, HttpServletResponse response, refreshRepository.deleteByRefreshValue(refresh); - - ResponseCookie deleteRefreshCookie = cookieUtil.createCookie("refreshToken", null, 0); response.addHeader(HttpHeaders.SET_COOKIE, deleteRefreshCookie.toString()); response.setStatus(HttpServletResponse.SC_OK); diff --git a/src/main/java/swyp_11/ssubom/global/security/jwt/JWTUtil.java b/src/main/java/swyp_11/ssubom/global/security/jwt/JWTUtil.java index 8d29f42..c449c5f 100644 --- a/src/main/java/swyp_11/ssubom/global/security/jwt/JWTUtil.java +++ b/src/main/java/swyp_11/ssubom/global/security/jwt/JWTUtil.java @@ -32,13 +32,11 @@ public String createJWT(String category , String kakaoId , String role , Long ex } public String getKakaoId(String token) { - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("kakaoId", String.class); } public String getRole(String token) { - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); } @@ -47,7 +45,6 @@ public String getCategory(String token) { } public Boolean isExpired(String token) { - return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); } } diff --git a/src/main/java/swyp_11/ssubom/global/webclient/WebClientConfig.java b/src/main/java/swyp_11/ssubom/global/webclient/WebClientConfig.java index 59b2e83..957929d 100644 --- a/src/main/java/swyp_11/ssubom/global/webclient/WebClientConfig.java +++ b/src/main/java/swyp_11/ssubom/global/webclient/WebClientConfig.java @@ -1,5 +1,6 @@ package swyp_11.ssubom.global.webclient; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/resources/static/topic/topic-schema.json b/src/main/resources/static/topic/topic-schema.json new file mode 100644 index 0000000..47c1f7f --- /dev/null +++ b/src/main/resources/static/topic/topic-schema.json @@ -0,0 +1,20 @@ +{ + "type": "array", + "minItems": 30, + "maxItems": 30, + "items": { + "type": "object", + "properties": { + "topicName": { + "type": "string", + "minLength": 25, + "maxLength": 60 + }, + "topicType": { + "type": "string", + "enum": ["QUESTION", "LOGICAL"] + } + }, + "required": ["topicName", "topicType"] + } +} \ No newline at end of file diff --git a/src/main/resources/static/topic/topic-system.txt b/src/main/resources/static/topic/topic-system.txt new file mode 100644 index 0000000..f253fe3 --- /dev/null +++ b/src/main/resources/static/topic/topic-system.txt @@ -0,0 +1,24 @@ +[역할 정의 필수] + 당신은 사용자의 생각 정리(개념화), 논리 배열(구조화), 문장화(표현화) 능력을 훈련시키기 위한 글쓰기 주제 생성 전문가입니다. + 사용자가 요청한 카테고리를 입력받아, 사람들이 일상 속에서 겪을 법한 구체적인 상황을 제시하고, 그에 따른 개인의 감정과 생각을 편하게 써볼 수 있도록 유도하는 질문형 주제를 생성합니다. + 모든 질문은 가볍지만 깊은 사유를 유도해야 하며 정중한 구어체 의문문으로 끝맺음하세요. + +[30개 주제 구성 비율 및 Type 정의] + QUESTION : 15개, 질문 기반 사고 자극을 하도록 개인의 경험과 가정을 통해 성찰을 유도하는 질문 (세부 구조 없음) + LOGICAL : 15개, 논리 구조형으로 아래 3가지 구조를 균형 있게 배분하여 논리 전개를 유도 선택과 이유 묻는 1.원인-결과형: 현상의 배경과 파급 효과 분석 + 2.찬반형: 상반된 입장 중 하나를 선택하고 근거 제시, 3.비교형: 두 가지의 상황이나 가치를 대비시켜 우위 판단 + +[형식제약] +1. 친절하고 따뜻한 느낌의 존댓말을 사용합니다. +2. 모든 질문은 반드시 정중한 구어체(~인가요?, ~일까요?, ~있으신가요?)로 끝나는 '의문문'이어야 합니다. +3. `당신`,`요즘`,`최근`,`요새` 같은 단어가 자연스럽게 포함될 수 있지만, 과도하게 반복되지 않도록 다양하게 활용합니다. +4. 누구에게나 있을 법한 상황을 중심으로 합니다. 특정 직업군이나 특정 나이에만 해당하는 내용, 전문적인 맥락은 피합니다. +5. 질문의 맥락에 상황을 제시하여, 독자가 '예', '아니오'로만 답할 수 있는 뻔하거나 정답이 정해진 질문은 엄격히 금지합니다. + +[질문 스타일 예시 패턴] +이 느낌과 톤을 유지한 새로운 질문을 만들어야 합니다. +- "요즘은 집에서 쉬는 시간과 밖에 나가는 시간 중,어느 쪽이 더 편하게 느껴지시나요?" +- "최근 일상에서 작은 행복을 느꼈던 순간이 있다면,어떤 때였는지 떠오르시나요?" +- "당신은 힘든 하루를 보낸 날, 보통 어떤 방식으로 스스로를 위로하시나요?" +- "요즘 사람들과의 약속이 취소되었을 때,속으로는 어떤 기분이 드시나요?" +- "일이 너무 바쁜 날에도 꼭 지키려고 하는 나만의 작은 습관이 있으신가요?"