diff --git a/ssupetition/build.gradle b/ssupetition/build.gradle index 375167a..d96ba0f 100644 --- a/ssupetition/build.gradle +++ b/ssupetition/build.gradle @@ -27,6 +27,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/news/NewsService.java b/ssupetition/src/main/java/com4table/ssupetition/domain/news/NewsService.java new file mode 100644 index 0000000..2368940 --- /dev/null +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/news/NewsService.java @@ -0,0 +1,179 @@ +package com4table.ssupetition.domain.news; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.openqa.selenium.By; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; +import org.springframework.stereotype.Service; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NewsService { + private static final String BASE_URL = "https://scatch.ssu.ac.kr/뉴스센터/주요뉴스/"; + private static final long DELAY_MS = 500L; // 서버 예절상 딜레이 + private static final Pattern DATE_PAT = Pattern.compile("(\\d{4})년\\s*(\\d{1,2})월\\s*(\\d{1,2})일"); + + /** 주요뉴스 크롤링: 1페이지부터 maxPages까지 */ + public CrawlResult crawlMajorNews(int maxPages) { + // WebDriver 설정 (USaintCrawler와 동일한 형태 유지) + ChromeOptions options = new ChromeOptions(); + options.setBinary("/usr/bin/google-chrome"); + options.addArguments("--headless=new"); + options.addArguments("--no-sandbox"); + options.addArguments("--disable-dev-shm-usage"); + WebDriver driver = new ChromeDriver(options); + + List results = new ArrayList<>(); + Set seen = new HashSet<>(); + + try { + int page = 1; + while (true) { + if (maxPages > 0 && page > maxPages) break; + + List links = extractLinksOnList(driver, page); + if (links.isEmpty()) { + log.info("No more links at page={}", page); + break; + } + for (String link : links) { + if (!seen.add(link)) continue; + try { + NewsItem item = parseArticle(driver, link); + if ((item.getTitle() != null && !item.getTitle().isEmpty()) + || (item.getDate() != null && !item.getDate().isEmpty())) { + results.add(item); + log.info("[OK] {} {}", item.getDate(), item.getTitle()); + } else { + log.info("[SKIP] {} (empty)", link); + } + Thread.sleep(DELAY_MS); + } catch (Exception e) { + log.warn("[ERR] {} :: {}", link, e.toString()); + } + } + page++; + } + return new CrawlResult(true, results, "count=" + results.size()); + } catch (Exception e) { + log.error("crawlMajorNews error: ", e); + return new CrawlResult(false, results, "error=" + e.getMessage()); + } finally { + driver.quit(); + } + } + + /** 목록 페이지에서 상세 링크 뽑기 */ + private List extractLinksOnList(WebDriver driver, int page) { + String url = (page == 1) ? BASE_URL : BASE_URL + "?paged=" + page; + log.info("GET {}", url); + driver.get(url); + + // 앵커 로드 대기 + new WebDriverWait(driver, Duration.ofSeconds(10)) + .until(ExpectedConditions.presenceOfElementLocated( + By.xpath("//a[contains(@href,'slug=')]") + )); + + // 상세로 가는 링크가 보통 slug= 파라미터를 포함 + List anchors = driver.findElements(By.xpath("//a[contains(@href, 'slug=')]")); + List links = anchors.stream() + .map(a -> a.getAttribute("href")) + .filter(Objects::nonNull) + .map(h -> h.split("#")[0]) + .filter(h -> h.contains("slug=")) + .distinct() + .collect(Collectors.toList()); + + log.info("page={} links={}", page, links.size()); + return links; + } + + /** 상세 페이지 파싱: 제목/날짜/본문/URL */ + private NewsItem parseArticle(WebDriver driver, String url) { + log.debug("Parse {}", url); + driver.get(url); + + String title = ""; + try { + WebElement h = new WebDriverWait(driver, Duration.ofSeconds(8)) + .until(ExpectedConditions.presenceOfElementLocated(By.xpath("(//h1|//h2)[1]"))); + title = Optional.ofNullable(h.getText()).orElse("").trim(); + } catch (TimeoutException ignored) {} + + // 본문: article p 우선, 없으면 모든 p + List paras = driver.findElements(By.cssSelector("article p")); + if (paras.isEmpty()) paras = driver.findElements(By.tagName("p")); + String content = paras.stream() + .map(WebElement::getText) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.joining("\n")); + + // 날짜: 페이지 전체 텍스트에서 yyyy년 m월 d일 + String bodyText = driver.findElement(By.tagName("body")).getText(); + Matcher m = DATE_PAT.matcher(bodyText); + String dateStr = ""; + if (m.find()) { + int y = Integer.parseInt(m.group(1)); + int mo = Integer.parseInt(m.group(2)); + int d = Integer.parseInt(m.group(3)); + dateStr = String.format("%04d-%02d-%02d", y, mo, d); + } + + return new NewsItem(title, dateStr, url, content); + } + + // ====== 결과/아이템 DTO (USaintCrawler의 내부 static 클래스 스타일로 구성) ====== + + @Getter + public static class NewsItem { + private final String title; + private final String date; // yyyy-MM-dd + private final String url; + private final String content; + + public NewsItem(String title, String date, String url, String content) { + this.title = title; + this.date = date; + this.url = url; + this.content = content; + } + } + + @Getter + @Setter + public static class CrawlResult { + private final boolean success; + private final List items; + private final String message; + + public CrawlResult(boolean success, List items, String message) { + this.success = success; + this.items = items; + this.message = message; + } + } + +} diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/controller/PostController.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/controller/PostController.java index d378e76..e4d5ab5 100644 --- a/ssupetition/src/main/java/com4table/ssupetition/domain/post/controller/PostController.java +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/controller/PostController.java @@ -1,14 +1,23 @@ package com4table.ssupetition.domain.post.controller; +import static com4table.ssupetition.domain.post.dto.gpt.PetitionDtos.*; + import com4table.ssupetition.domain.post.domain.Post; import com4table.ssupetition.domain.post.dto.PostRequest; import com4table.ssupetition.domain.post.dto.PostResponse; import com4table.ssupetition.domain.post.dto.ResponseDto; +import com4table.ssupetition.domain.post.dto.gpt.PetitionDtos; import com4table.ssupetition.domain.post.service.PostAnswerService; import com4table.ssupetition.domain.post.service.PostService; +import com4table.ssupetition.global.exception.BaseResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -37,14 +46,54 @@ public ResponseEntity addPost(@RequestBody PostRequest.AddDTO addDTO, @Pat return ResponseEntity.ok(createdPost); } + //AI 글 수정 + @Operation(summary = "최신판", description = "(최신판) 게시글 작성할 때 AI한테 다듬어달라고 요청하는 API") + @PostMapping("/ai/body") + public BaseResponse summaryAI(@RequestBody GenerateRequest request){ + return BaseResponse.builder() + .code(200) + .message("AI가 글을 다듬었습니다.") + .isSuccess(true) + .data(postService.makeBestSingleVersion(request)) + .build(); + } + //전체 검색 - @Operation(description = "전체 게시글들 가져오는 API") + @Operation(description = "전체 게시글들 가져오는 API-> 모아보기에서 이걸 쓰면 될 듯->키워드 별로 가져오는 거 같음") @PostMapping("/search") - public List postSearch(@RequestBody Map body) { + public Page postSearch(@RequestBody Map body , @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){ String keyword = body.get("keyword"); - return postService.searchPosts(keyword); + return postService.searchPosts(keyword,pageable); + } + + //필터링된 검색 결과 가져오기 + @Operation(summary = "최신판", description = "(최신판) 필터링된 상태로 게시글들을 가져오는 API // FILTER : all(모아보기), event(행사), partnership(제휴), facility(시설), study(교과), report(신고) 뒤에 한국어 괄호 제외하고 넣어주면 됨 ") + @GetMapping("/search/{category}") + public BaseResponse> getListsWithFilter(@PathVariable(name = "category") String category, @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){ + return BaseResponse.>builder() + .isSuccess(true) + .message("필터링된 게시글들을 가져왔습니다.") + .code(200) + .data(postService.getFilterList(category, pageable)) + .build(); } + //최신 검색 + @Operation(summary = "최신판", description = "(최신판) 최신 결과 가져오기") + @GetMapping("/search/current") + public BaseResponse> getListsRecent(@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable){ + return BaseResponse.>builder() + .isSuccess(true) + .message("최신 게시글들을 가져왔습니다.") + .code(200) + .data(postService.getCurrentList(pageable)) + .build(); + } + + //학교 뉴스 크롤링 + + + @Operation(description = "위의 설명을 보면 존재하는 카테고리에 속하는 게시글들을 제공하는 API") @PostMapping("/search/sorted-by-agree/{category}") diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionRequest.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionRequest.java new file mode 100644 index 0000000..8e96372 --- /dev/null +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionRequest.java @@ -0,0 +1,27 @@ +package com4table.ssupetition.domain.post.dto.gpt; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatCompletionRequest { + private String model; + private List messages; + private Double temperature; + private Integer max_tokens; + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder + public static class Message { + private String role; // "system" | "user" + private String content; + } +} diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionResponse.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionResponse.java new file mode 100644 index 0000000..82c570f --- /dev/null +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/ChatCompletionResponse.java @@ -0,0 +1,30 @@ +package com4table.ssupetition.domain.post.dto.gpt; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatCompletionResponse { + + + private List choices; + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class Choice { + private int index; + private Message message; + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor + public static class Message { + private String role; + private String content; + } + } +} diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/PetitionDtos.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/PetitionDtos.java new file mode 100644 index 0000000..16210a3 --- /dev/null +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/dto/gpt/PetitionDtos.java @@ -0,0 +1,23 @@ +package com4table.ssupetition.domain.post.dto.gpt; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +public class PetitionDtos +{ + + @Getter + @Setter + public static class GenerateRequest { + private String category; // 예: "시설", "수업", "행정" + private String titleDraft; // 사용자 초안 제목 + private String bodyDraft; // 사용자 초안 본문 + } + + @Getter @AllArgsConstructor + public static class GenerateResponse { + private final String title; + private final String body; + } +} diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/repository/PostRepository.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/repository/PostRepository.java index d7067cd..a5a7fd8 100644 --- a/ssupetition/src/main/java/com4table/ssupetition/domain/post/repository/PostRepository.java +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/repository/PostRepository.java @@ -4,6 +4,9 @@ import com4table.ssupetition.domain.post.domain.Post; import com4table.ssupetition.domain.post.enums.Category; import com4table.ssupetition.domain.post.enums.Type; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -15,8 +18,9 @@ public interface PostRepository extends JpaRepository { // List findByPostType(Long postCategoryId); List findByUser_UserId(Long user); + Page findAllByPostCategory(Category postCategory,Pageable pageable); - + Page findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Pageable pageable); // 최다 동의 순으로 정렬 @Query("SELECT p FROM Post p ORDER BY p.agree DESC") diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/post/service/PostService.java b/ssupetition/src/main/java/com4table/ssupetition/domain/post/service/PostService.java index 47e2d4d..796df5b 100644 --- a/ssupetition/src/main/java/com4table/ssupetition/domain/post/service/PostService.java +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/post/service/PostService.java @@ -1,5 +1,7 @@ package com4table.ssupetition.domain.post.service; +import static com4table.ssupetition.domain.post.dto.gpt.PetitionDtos.*; + import com.fasterxml.jackson.databind.ObjectMapper; import com4table.ssupetition.domain.mail.service.MailService; import com4table.ssupetition.domain.mypage.domain.AgreePost; @@ -13,6 +15,9 @@ import com4table.ssupetition.domain.post.dto.PostRequest; import com4table.ssupetition.domain.post.dto.PostResponse; import com4table.ssupetition.domain.post.dto.ResponseDto; +import com4table.ssupetition.domain.post.dto.gpt.ChatCompletionRequest; +import com4table.ssupetition.domain.post.dto.gpt.ChatCompletionResponse; +import com4table.ssupetition.domain.post.dto.gpt.PetitionDtos; import com4table.ssupetition.domain.post.enums.Category; import com4table.ssupetition.domain.post.enums.Type; import com4table.ssupetition.domain.post.repository.EmbeddingValueRepository; @@ -24,10 +29,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.java.Log; import lombok.extern.slf4j.Slf4j; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; import java.util.*; import java.util.stream.Collectors; @@ -49,6 +58,9 @@ public class PostService { private final EmbeddingService embeddingService; private final QdrantService qdrantService; + private final WebClient openAiWebClient; + private static final String MODEL = "gpt-4o-mini"; + public Post addPost(Long userId, PostRequest.AddDTO addDTO) { User user = userRepository.findById(userId) @@ -122,29 +134,13 @@ public Post addPost(Long userId, PostRequest.AddDTO addDTO) { return savedPost; } - public List searchPosts( String keyword) { - - List posts = postRepository.findAll(); - - // 제목 또는 내용에 키워드가 포함된 게시물 필터링 - List filteredPosts = posts.stream() - .peek(post -> log.info("원본 포스트 제목: {}, 내용: {}", post.getTitle(), post.getContent())) // 원본 포스트 로그 - .filter(post -> { - boolean matches = post.getTitle().contains(keyword) || post.getContent().contains(keyword); - if (matches) { - log.info("필터링된 포스트 제목: {}, 내용: {}", post.getTitle(), post.getContent()); - } - return matches; - }) - .collect(Collectors.toList()); + public Page searchPosts( String keyword, Pageable pageable) { + Page posts = postRepository.findByTitleContainingOrContentContaining(keyword, keyword, pageable); - // DTO로 변환하여 반환 - return filteredPosts.stream() - .map(post -> { - log.info("DTO 변환 포스트 제목: {}, 내용: {}", post.getTitle(), post.getContent()); - return convertToDto(post); - }) - .collect(Collectors.toList()); + return posts.map(post -> { + log.info("검색된 포스트 제목: {}, 내용: {}", post.getTitle(), post.getContent()); + return convertToDto(post); + }); } @@ -492,6 +488,99 @@ public List getPostsByUserIdAndType(Long userId) { .collect(Collectors.toList()); } + //필터링해서 결과 가져오기 + public Page getFilterList(String category,Pageable pageable){ + Page posts; + if(category.equals("all")){ + posts = postRepository.findAll(pageable); + }else { + posts = postRepository.findAllByPostCategory(Category.valueOf(category),pageable); + } + return posts.map(post -> { + log.info("검색된 포스트 제목: {}, 내용: {}", post.getTitle(), post.getContent()); + return convertToDto(post); + }); + } + + public Page getCurrentList(Pageable pageable){ + Page posts = postRepository.findAll(pageable); + return posts.map(this::convertToDto + ); + } + + + + public GenerateResponse makeBestSingleVersion(GenerateRequest req) { + String systemPrompt = String.join("\n", + "너는 '숭민청원' 서비스용 청원문 작성 전문가다.", + "목표: 학생 불만을 담당부서가 즉시 이해하고 조치할 수 있게, 간결하고 설득력 있는 '단 하나의 최종안'을 만든다.", + "- 출력은 반드시 '제목'과 '본문'만 포함한다. 다른 설명, 리스트, 대안, 선택지는 금지.", + "- 사실을 과장하거나 새 사실을 추가하지 말고, 사용자가 준 정보만 명확히 정리한다.", + "- 제목: 40자 이내, 핵심키워드 포함, 중복어구 금지.", + "- 본문: 600자 이내, 문제상황→근거/영향→요청사항 순서. 비난/감정표현 최소화, 구체적 요청 명시.", + "- 카테고리(예: 시설/수업/행정)를 제목 첫머리에 대괄호로 표시한다. 예: [시설] ...", + "- 최종 결과는 단 하나의 버전만 제시하고, 불필요한 접두사/접미사/마크다운 없이 텍스트만 출력한다." + ); + + String userPrompt = """ + [카테고리] + %s + + [제목 초안] + %s + + [본문 초안] + %s + + 위 정보를 기준으로 '단 하나의 최종안'만 작성해줘. + """.formatted( + nullSafe(req.getCategory()), nullSafe(req.getTitleDraft()), nullSafe(req.getBodyDraft()) + ); + + ChatCompletionRequest request = ChatCompletionRequest.builder() + .model(MODEL) + .temperature(0.2) // 결정적/일관된 출력 + .max_tokens(900) + .messages(List.of( + new ChatCompletionRequest.Message("system", systemPrompt), + new ChatCompletionRequest.Message("user", userPrompt) + )) + .build(); + + ChatCompletionResponse res = openAiWebClient.post() + .uri("/chat/completions") + .bodyValue(request) + .retrieve() + .bodyToMono(ChatCompletionResponse.class) + .block(); // MVC에서는 block() 사용 OK + + if (res == null || res.getChoices() == null || res.getChoices().isEmpty() + || res.getChoices().get(0).getMessage() == null) { + throw new IllegalStateException("OpenAI 응답이 비어 있습니다."); + } + + // 모델 출력은 "제목\n본문" 형태로 오므로 간단 파싱 (규칙을 system에서 강제했기 때문에 안정적) + String content = res.getChoices().get(0).getMessage().getContent().trim(); + String[] lines = content.split("\\r?\\n", 2); + + String title = lines.length > 0 ? lines[0].trim() : ""; + String body = lines.length > 1 ? lines[1].trim() : ""; + + // 방어적 후처리: 길이 컷팅 + title = cut(title, 80); + body = cut(body, 2000); + + return new GenerateResponse(title, body); + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } + + private static String cut(String s, int max) { + if (s == null) return ""; + return s.length() > max ? s.substring(0, max) : s; + } } diff --git a/ssupetition/src/main/java/com4table/ssupetition/domain/searching/config/OpenAiConfig.java b/ssupetition/src/main/java/com4table/ssupetition/domain/searching/config/OpenAiConfig.java index 6453e7f..21699d6 100644 --- a/ssupetition/src/main/java/com4table/ssupetition/domain/searching/config/OpenAiConfig.java +++ b/ssupetition/src/main/java/com4table/ssupetition/domain/searching/config/OpenAiConfig.java @@ -2,7 +2,11 @@ import lombok.Getter; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; @Getter @Configuration @@ -13,4 +17,17 @@ public class OpenAiConfig { @Value("${openai.embedding.model}") private String embeddingModel; + + @Configuration + public class OpenAIClientConfig { + + @Bean + public WebClient openAiWebClient(@Value("${openai.api.key}") String apiKey) { + return WebClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + } } \ No newline at end of file