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
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.sofa.linkiving.domain.link.ai;

import com.sofa.linkiving.domain.link.enums.Format;

public interface AiSummaryClient {
/**
* AI 서버에 요약 요청을 보냅니다.
* @param linkId 링크 ID
* @param url 요약할 URL
* @param format 요약 모드
* @return 요약된 텍스트
*/
String generateSummary(Long linkId, String url, Format format);

/**
* 기존 요약과 신규 요약 내용을 비교합니다.
* @param existingSummary 기존 요약
* @param newSummary 신규 요약
* @return 요약 비교 정보
*/
String comparisonSummary(String existingSummary, String newSummary);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.sofa.linkiving.domain.link.ai;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import com.sofa.linkiving.domain.link.enums.Format;

@Component
@Primary
public class MockAiSummaryClient implements AiSummaryClient {

@Override
public String generateSummary(Long linkId, String url, Format format) {

if (format == Format.DETAILED) {
return """
[자세한 요약 (Mock)]
OpenFeign 도입을 대비하여 Interface 기반 설계를 적용했습니다.
1. AiSummaryClient 인터페이스를 정의하여 의존성을 역전시켰습니다.
2. 현재는 MockAiSummaryClient가 동작하지만, 추후 실제 구현체로 교체하기 쉽습니다.
3. 비즈니스 로직은 AI 서버의 통신 방식(HTTP, gRPC 등)에 영향을 받지 않습니다.
""";
} else {
return """
[간결한 요약 (Mock)]
OpenFeign 도입을 위해 인터페이스 패턴을 적용하여, 코드 수정 없이 구현체 교체가 가능한 확장성 있는 구조를 만들었습니다.
""";
}
}

@Override
public String comparisonSummary(String existingSummary, String newSummary) {
return """
[변경 사항 분석]
기존 요약 대비 다음 내용이 보강되었습니다:
- AI 아키텍처 설계 방식에 대한 구체적인 설명 추가
- OpenFeign 도입의 이점 명시
(이 내용은 Mock 데이터이며, 실제 AI는 두 텍스트의 차이를 분석하여 제공합니다.)
""";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@
import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq;
import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;
import com.sofa.linkiving.security.annotation.AuthMember;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

Expand Down Expand Up @@ -74,4 +77,11 @@ ResponseEntity<BaseResponse<LinkRes>> updateMemo(
@Valid @RequestBody LinkMemoUpdateReq request,
@AuthMember Member member
);

@Operation(summary = "요약 재생성", description = "요약을 재생성 하고 신규 요약 기존 요약, 기존 및 신규 요약 비교 정보을 제공합니다.")
BaseResponse<RecreateSummaryResponse> recreateSummary(
@PathVariable Long id,
@Valid @RequestParam @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format,
@AuthMember Member member
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq;
import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.link.facade.LinkFacade;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.common.BaseResponse;
Expand Down Expand Up @@ -129,4 +131,15 @@ public ResponseEntity<BaseResponse<LinkRes>> updateMemo(
LinkRes response = linkFacade.updateMemo(id, member, request.memo());
return ResponseEntity.ok(BaseResponse.success(response, "메모 수정 완료"));
}

@Override
@GetMapping("/{id}/summary")
public BaseResponse<RecreateSummaryResponse> recreateSummary(
@PathVariable Long id,
@Valid @RequestParam Format format,
@AuthMember Member member
) {
RecreateSummaryResponse response = linkFacade.recreateSummary(member, id, format);
return BaseResponse.success(response, "요약 재성성 완료");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.sofa.linkiving.domain.link.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;

@Builder
public record RecreateSummaryResponse(
@Schema(description = "기존 요약")
String existingSummary,
@Schema(description = "신규 요약")
String newSummary,
@Schema(description = "비교 정보")
String comparison
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
public enum LinkErrorCode implements ErrorCode {

LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "L-001", "링크를 찾을 수 없습니다."),
DUPLICATE_URL(HttpStatus.BAD_REQUEST, "L-002", "이미 저장된 URL입니다.");
DUPLICATE_URL(HttpStatus.BAD_REQUEST, "L-002", "이미 저장된 URL입니다."),
SUMMARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "L-010", "요약 정보를 찾을 수 없습니다.");

private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes;
import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.link.service.LinkService;
import com.sofa.linkiving.domain.link.service.SummaryService;
import com.sofa.linkiving.domain.member.entity.Member;

import lombok.RequiredArgsConstructor;

@Service
@Transactional
@RequiredArgsConstructor
public class LinkFacade {

private final LinkService linkService;
private final SummaryService summaryService;

public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) {
return linkService.createLink(member, url, title, memo, imageUrl);
Expand Down Expand Up @@ -48,4 +54,21 @@ public Page<LinkRes> getLinkList(Member member, Pageable pageable) {
public LinkDuplicateCheckRes checkDuplicate(Member member, String url) {
return linkService.checkDuplicate(member, url);
}

@Transactional(readOnly = true)
public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Format format) {

String url = linkService.getLink(linkId, member).url();

String existingSummary = summaryService.getSummary(linkId).getContent();
String newSummary = summaryService.createSummary(linkId, url, format);

String comparison = summaryService.comparisonSummary(existingSummary, newSummary);

return RecreateSummaryResponse.builder()
.existingSummary(existingSummary)
.newSummary(newSummary)
.comparison(comparison)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.sofa.linkiving.domain.link.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import com.sofa.linkiving.domain.link.entity.Summary;

@Repository
public interface SummaryRepository extends JpaRepository<Summary, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.sofa.linkiving.domain.link.service;

import org.springframework.stereotype.Service;

import com.sofa.linkiving.domain.link.entity.Summary;
import com.sofa.linkiving.domain.link.error.LinkErrorCode;
import com.sofa.linkiving.domain.link.repository.SummaryRepository;
import com.sofa.linkiving.global.error.exception.BusinessException;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class SummaryQueryService {
private final SummaryRepository summaryRepository;

public Summary getSummary(Long linkId) {
return summaryRepository.findById(linkId).orElseThrow(
() -> new BusinessException(LinkErrorCode.SUMMARY_NOT_FOUND)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sofa.linkiving.domain.link.service;

import org.springframework.stereotype.Service;

import com.sofa.linkiving.domain.link.ai.AiSummaryClient;
import com.sofa.linkiving.domain.link.entity.Summary;
import com.sofa.linkiving.domain.link.enums.Format;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class SummaryService {
private final SummaryQueryService summaryQueryService;
private final AiSummaryClient aiSummaryClient;

public String createSummary(Long linkId, String url, Format format) {
return aiSummaryClient.generateSummary(linkId, url, format);
}

public String comparisonSummary(String existingSummary, String newSummary) {
return aiSummaryClient.comparisonSummary(existingSummary, newSummary);
}

public Summary getSummary(Long linkId) {
return summaryQueryService.getSummary(linkId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.sofa.linkiving.domain.link.ai;

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import com.sofa.linkiving.domain.link.enums.Format;

public class MockAiSummaryClientTest {
private final MockAiSummaryClient client = new MockAiSummaryClient();

@Test
@DisplayName("DETAILED 포맷 요청 시 상세 요약 텍스트 반환")
void generateSummary_Detailed() {
// when
String result = client.generateSummary(1L, "url", Format.DETAILED);

// then
assertThat(result).contains("[자세한 요약 (Mock)]");
assertThat(result).contains("OpenFeign 도입");
}

@Test
@DisplayName("SIMPLE 포맷 요청 시 간결한 요약 텍스트 반환")
void generateSummary_Simple() {
// when
String result = client.generateSummary(1L, "url", Format.CONCISE);

// then
assertThat(result).contains("[간결한 요약 (Mock)]");
}

@Test
@DisplayName("comparisonSummary 호출 시 변경 사항 분석 텍스트 반환")
void comparisonSummary() {
// when
String result = client.comparisonSummary("old", "new");

// then
assertThat(result).contains("[변경 사항 분석]");
assertThat(result).contains("보강되었습니다");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.sofa.linkiving.domain.link.facade;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.BDDMockito.*;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import com.sofa.linkiving.domain.link.dto.response.LinkRes;
import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse;
import com.sofa.linkiving.domain.link.entity.Summary;
import com.sofa.linkiving.domain.link.enums.Format;
import com.sofa.linkiving.domain.link.service.LinkService;
import com.sofa.linkiving.domain.link.service.SummaryService;
import com.sofa.linkiving.domain.member.entity.Member;

@ExtendWith(MockitoExtension.class)
public class LinkFacadeTest {

@InjectMocks
private LinkFacade linkFacade;

@Mock
private LinkService linkService;

@Mock
private SummaryService summaryService;

@Test
@DisplayName("요약 재생성 및 비교 분석 성공 테스트")
void shouldReturnRecreateSummaryResponseWhenRecreateSummary() {
// given
Long linkId = 1L;
Member member = mock(Member.class); // Member 객체 Mock
Format format = Format.DETAILED;
String url = "https://example.com";
String existingSummaryBody = "기존 요약 내용입니다.";
String newSummaryBody = "새로운 상세 요약 내용입니다.";
String comparisonBody = "기존 대비 상세 내용이 추가되었습니다.";

// 1. LinkService Mocking (URL 가져오기)
LinkRes mockLinkRes = mock(LinkRes.class);
given(mockLinkRes.url()).willReturn(url);
given(linkService.getLink(linkId, member)).willReturn(mockLinkRes);

// 2. SummaryService (기존 요약 가져오기)
Summary mockSummary = mock(Summary.class);
given(mockSummary.getContent()).willReturn(existingSummaryBody);
given(summaryService.getSummary(linkId)).willReturn(mockSummary);

// 3. SummaryService (새 요약 생성 및 비교)
given(summaryService.createSummary(linkId, url, format)).willReturn(newSummaryBody);
given(summaryService.comparisonSummary(existingSummaryBody, newSummaryBody)).willReturn(comparisonBody);

// when
RecreateSummaryResponse response = linkFacade.recreateSummary(member, linkId, format);

// then
assertThat(response).isNotNull();
assertThat(response.existingSummary()).isEqualTo(existingSummaryBody);
assertThat(response.newSummary()).isEqualTo(newSummaryBody);
assertThat(response.comparison()).isEqualTo(comparisonBody);

// verify
verify(linkService).getLink(linkId, member);
verify(summaryService).getSummary(linkId);
verify(summaryService).createSummary(linkId, url, format);
verify(summaryService).comparisonSummary(existingSummaryBody, newSummaryBody);
}
}
Loading