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
Expand Up @@ -8,6 +8,7 @@
import org.sopt.solply_server.domain.admin.course.dto.response.AdminCourseUpdateResponse;
import org.sopt.solply_server.domain.admin.course.dto.response.AdminCourseUpsertResponse;
import org.sopt.solply_server.domain.admin.course.service.AdminCourseService;
import org.sopt.solply_server.domain.course.service.facade.CourseEmbeddingFacade;
import org.sopt.solply_server.global.annotation.CurrentUserId;
import org.sopt.solply_server.global.dto.CustomApiResponse;
import org.springframework.http.ResponseEntity;
Expand All @@ -32,6 +33,7 @@
@RequestMapping("/api/admin/courses")
public class AdminCourseController {
private final AdminCourseService adminCourseService;
private final CourseEmbeddingFacade courseEmbeddingFacade;

@Operation(summary = "어드민 코스 생성", description = "어드민이 새 코스를 생성합니다.")
@PostMapping
Expand Down Expand Up @@ -88,6 +90,13 @@ public ResponseEntity<CustomApiResponse<Void>> deleteCourse(
return CustomApiResponse.success("코스 삭제 성공", null);
}

@Operation(summary = "코스 검색 문서 일괄 임베딩", description = "INIT·FAILED 상태의 코스 검색 문서를 즉시 임베딩합니다. 테스트 및 초기 데이터 세팅용.")
@PostMapping("/search-documents/initialize")
public ResponseEntity<CustomApiResponse<Void>> initializeCourseSearchDocuments() {
courseEmbeddingFacade.initializePendingDocuments();
return CustomApiResponse.success("코스 검색 문서 임베딩 초기화 완료", null);
}

@Operation(summary = "어드민 코스 상태 수정", description = "어드민이 코스의 활성화 상태를 수정합니다.")
@PatchMapping("/{id}/activation")
public ResponseEntity<CustomApiResponse<AdminCourseUpsertResponse>> updateCourseStatus(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package org.sopt.solply_server.domain.course.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.MapsId;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.sopt.solply_server.domain.place.entity.EmbeddingStatus;
import org.sopt.solply_server.global.ai.FloatArrayConverter;
import org.sopt.solply_server.global.entity.BaseTimeEntity;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "course_search_documents")
public class CourseSearchDocument extends BaseTimeEntity {

@Id
private Long courseId;

@MapsId
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
private Course course;

@Column(nullable = false, columnDefinition = "TEXT")
private String retrievalText;

@Convert(converter = FloatArrayConverter.class)
@Column(columnDefinition = "MEDIUMBLOB")
private float[] embedding;

@Column(length = 100)
private String embeddingModel;

@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EmbeddingStatus status;

public static CourseSearchDocument init(Course course) {
CourseSearchDocument doc = new CourseSearchDocument();
doc.course = course;
doc.retrievalText = "";
doc.status = EmbeddingStatus.INIT;
return doc;
}

public void updateEmbedding(String retrievalText, float[] embedding, String embeddingModel) {
this.retrievalText = retrievalText;
this.embedding = embedding;
this.embeddingModel = embeddingModel;
this.status = EmbeddingStatus.READY;
}

public void markFailed() {
this.status = EmbeddingStatus.FAILED;
}

public void markDirty() {
if (this.status != EmbeddingStatus.INIT) {
this.status = EmbeddingStatus.DIRTY;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,23 @@ List<String> findCourseNamesByBookmarkedCourses(@Param("courseIds") Set<Long> co
@Param("namePattern") String namePattern);

Optional<Course> findByIdAndActiveTrue(Long id);

/**
* 추천 응답 조립용 — course + tag + town + coursePlaces + place + placeTags 를 모두 JOIN FETCH.
* 임베딩 유사도 Top-K 선정 후 상세 데이터를 한 번에 로딩할 때 사용한다.
*/
/**
* 추천 응답 조립용 — course + tag + town + coursePlaces + place 를 JOIN FETCH.
* place.placeTags는 @BatchSize lazy loading으로 처리한다 (두 컬렉션 동시 JOIN FETCH 시 MultipleBagFetchException 발생).
*/
@Query("""
SELECT DISTINCT c FROM Course c
JOIN FETCH c.tag
JOIN FETCH c.town
LEFT JOIN FETCH c.coursePlaces cp
LEFT JOIN FETCH cp.place p
WHERE c.id IN :courseIds
AND c.active = true
""")
List<Course> findByIdInWithAllForRecommendation(@Param("courseIds") List<Long> courseIds);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.sopt.solply_server.domain.course.repository;

import java.util.Collection;
import java.util.List;
import org.sopt.solply_server.domain.course.entity.CourseSearchDocument;
import org.sopt.solply_server.domain.place.entity.EmbeddingStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface CourseSearchDocumentRepository extends JpaRepository<CourseSearchDocument, Long> {

@Query("""
SELECT d FROM CourseSearchDocument d
JOIN FETCH d.course c
JOIN FETCH c.tag
JOIN FETCH c.town
WHERE c.active = true
AND c.isShared = true
AND c.town.id = :townId
AND d.embedding IS NOT NULL
""")
List<CourseSearchDocument> findActiveSharedByTownIdWithEmbedding(@Param("townId") Long townId);

@Query("""
SELECT d FROM CourseSearchDocument d
JOIN FETCH d.course c
JOIN FETCH c.tag
JOIN FETCH c.town
WHERE c.active = true
AND c.isShared = true
AND c.town.parent.id = :parentTownId
AND d.embedding IS NOT NULL
""")
List<CourseSearchDocument> findActiveSharedByParentTownIdWithEmbedding(@Param("parentTownId") Long parentTownId);

@Query("""
SELECT d.courseId FROM CourseSearchDocument d
WHERE d.status IN :statuses
""")
List<Long> findCourseIdsByStatusIn(@Param("statuses") Collection<EmbeddingStatus> statuses);

@Modifying
@Query("""
UPDATE CourseSearchDocument d
SET d.status = 'DIRTY', d.updatedAt = CURRENT_TIMESTAMP
WHERE d.status <> 'INIT'
AND d.courseId = :courseId
""")
int markDirtyByCourseId(@Param("courseId") Long courseId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.sopt.solply_server.domain.course.service;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.sopt.solply_server.domain.course.entity.Course;
import org.sopt.solply_server.domain.course.entity.CourseSearchDocument;
import org.sopt.solply_server.domain.course.repository.CourseRepository;
import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository;
import org.sopt.solply_server.global.ai.EmbeddingService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class CourseEmbeddingBatchProcessor {

private final CourseSearchDocumentRepository courseSearchDocumentRepository;
private final CourseRepository courseRepository;
private final CourseRetrievalTextBuilder retrievalTextBuilder;
private final EmbeddingService embeddingService;

/**
* 단건 임베딩. 호출자의 트랜잭션 내에서 동작한다.
*/
public void processOne(CourseSearchDocument doc) {
List<Course> courses = courseRepository.findByIdInWithAllForRecommendation(List.of(doc.getCourseId()));
if (courses.isEmpty()) {
log.warn("코스를 찾을 수 없습니다 - courseId={}", doc.getCourseId());
doc.markFailed();
return;
}
Course course = courses.get(0);

try {
String retrievalText = retrievalTextBuilder.build(course);
float[] embedding = embeddingService.embed(retrievalText);
doc.updateEmbedding(retrievalText, embedding, embeddingService.getModelName());
log.info("코스 임베딩 완료 - courseId={}", doc.getCourseId());
} catch (Exception e) {
doc.markFailed();
log.warn("코스 임베딩 실패 - courseId={}, error={}", doc.getCourseId(), e.getMessage());
}
}

@Transactional
public void processBatch(List<Long> courseIds) {
Map<Long, Course> courseById = courseRepository.findByIdInWithAllForRecommendation(courseIds)
.stream().collect(Collectors.toMap(Course::getId, c -> c));

List<CourseSearchDocument> docs = courseSearchDocumentRepository.findAllById(courseIds);

List<CourseSearchDocument> embeddableDocs = new ArrayList<>();
List<String> retrievalTexts = new ArrayList<>();

for (CourseSearchDocument doc : docs) {
Course course = courseById.get(doc.getCourseId());
if (course == null) {
log.warn("코스를 찾을 수 없어 건너뜁니다 - courseId={}", doc.getCourseId());
continue;
}
embeddableDocs.add(doc);
retrievalTexts.add(retrievalTextBuilder.build(course));
}

if (embeddableDocs.isEmpty()) return;

List<float[]> embeddings = embeddingService.embedAll(retrievalTexts);

for (int i = 0; i < embeddableDocs.size(); i++) {
embeddableDocs.get(i).updateEmbedding(retrievalTexts.get(i), embeddings.get(i), embeddingService.getModelName());
}

log.info("코스 배치 임베딩 완료 - count={}", embeddableDocs.size());
}

@Transactional
public void markChunkFailed(List<Long> courseIds) {
List<CourseSearchDocument> docs = courseSearchDocumentRepository.findAllById(courseIds);
docs.forEach(CourseSearchDocument::markFailed);
log.warn("코스 청크 임베딩 실패로 FAILED 처리 - count={}", docs.size());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package org.sopt.solply_server.domain.course.service;

import java.util.List;
import java.util.Objects;
import org.sopt.solply_server.domain.course.entity.Course;
import org.sopt.solply_server.domain.course.entity.CoursePlace;
import org.sopt.solply_server.domain.tag.entity.Tag;
import org.springframework.stereotype.Component;

@Component
public class CourseRetrievalTextBuilder {

/**
* 코스 임베딩용 retrieval_text 생성.
* 호출자는 course.coursePlaces → place → placeTags 가 이미 로딩된 상태여야 한다.
*/
public String build(Course course) {
String courseName = course.getName();
String townName = course.getTown().getName();

Tag tag = course.getTag();
String tagName = tag != null ? tag.getName() : "기타";
String tagMeaning = (tag != null && tag.getMeaning() != null) ? tag.getMeaning() : null;

String introduction = course.getIntroduction();
int placeCount = course.getCoursePlaces().size();
String lengthDesc = placeCount <= 3 ? "짧은" : "반나절";

List<String> placeMainTagNames = course.getCoursePlaces().stream()
.map(CoursePlace::getPlace)
.map(place -> place.getMainTag().filter(Tag::isActive).map(Tag::getName).orElse(null))
.filter(Objects::nonNull)
.distinct()
.toList();

String placeTagList = placeMainTagNames.isEmpty() ? "다양한 장소" : String.join(", ", placeMainTagNames);

StringBuilder sb = new StringBuilder();
sb.append(courseName).append("은 ").append(townName).append("에 위치한 ").append(tagName).append(" 코스다.\n");
if (tagMeaning != null && !tagMeaning.isBlank()) {
sb.append(tagMeaning).append("\n");
}
if (introduction != null && !introduction.isBlank()) {
sb.append(introduction).append("\n");
}
sb.append("총 ").append(placeCount).append("개 장소로 구성된 ").append(lengthDesc).append(" 코스다.\n");
sb.append("주요 장소 유형: ").append(placeTagList);

return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
import org.sopt.solply_server.domain.town.entity.Town;
import org.sopt.solply_server.domain.town.util.TownValidator;
import org.sopt.solply_server.domain.user.entity.User;
import org.sopt.solply_server.domain.course.repository.CourseSearchDocumentRepository;
import org.sopt.solply_server.domain.course.service.event.CourseCreatedEvent;
import org.sopt.solply_server.global.exception.BusinessException;
import org.sopt.solply_server.global.exception.EntityNotFoundException;
import org.sopt.solply_server.global.exception.ErrorCode;
import org.sopt.solply_server.global.util.EntityLoader;
import org.sopt.solply_server.global.util.TagViewUtils;
import org.sopt.solply_server.global.util.s3.ImageUrlProvider;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -54,6 +57,9 @@ public class CourseService {
private final CourseUtils courseUtils;
private final EntityLoader entityLoader;

private final CourseSearchDocumentRepository courseSearchDocumentRepository;
private final ApplicationEventPublisher applicationEventPublisher;

private final TownValidator townValidator;
private final CoursePlaceValidator coursePlaceValidator;
private final TagValidator tagValidator;
Expand Down Expand Up @@ -161,6 +167,8 @@ public CourseAddPlaceResponse addPlaceToCourse(final Long userId, final Long pla
if (originCourse.isCreatedBy(userId)) {
// 본인 코스에 장소 추가
coursePlaceService.addPlaceToMyCourse(originCourse, place);
// 장소 구성이 바뀌었으므로 임베딩 문서를 DIRTY 상태로 전환
courseSearchDocumentRepository.markDirtyByCourseId(originCourse.getId());
} else {
// 코스 복제 후 장소 추가
List<PlaceInCourseInfo> placesInCourse = originCourse.getPlacesInCourseInfo();
Expand Down Expand Up @@ -328,6 +336,9 @@ private void updateCourse(Course course, String courseName, String courseDescrip

courseRepository.deleteCoursePlacesByCourseId(course.getId());
coursePlaceService.addPlacesToTargetCourse(course, placeInfosInCourseForOrder, placesToAdd);

// 코스 내용이 바뀌었으므로 임베딩 문서를 DIRTY 상태로 전환
courseSearchDocumentRepository.markDirtyByCourseId(course.getId());
}

/**
Expand Down Expand Up @@ -355,6 +366,9 @@ private Course createNewCourse(User user, final String courseName, final String
// 북마크 등록
courseBookmarkFacade.createCourseBookmark(user.getId(), newCourse.getId());

// 임베딩 파이프라인 트리거 (트랜잭션 커밋 후 비동기 실행)
applicationEventPublisher.publishEvent(new CourseCreatedEvent(newCourse.getId()));

return newCourse;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.sopt.solply_server.domain.course.service.event;

public record CourseCreatedEvent(Long courseId) {
}
Loading
Loading