diff --git a/build.gradle b/build.gradle index f021f8d..cb4156a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java' + id 'org.jetbrains.kotlin.jvm' version '1.9.22' id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.6' } @@ -13,6 +14,10 @@ java { } } +kotlin { + jvmToolchain(21) +} + configurations { compileOnly { extendsFrom annotationProcessor @@ -33,6 +38,10 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' implementation 'org.springframework.boot:spring-boot-starter-security' + // batch + implementation 'org.springframework.boot:spring-boot-starter-batch' + testImplementation 'org.springframework.batch:spring-batch-test' + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -44,11 +53,17 @@ dependencies { // db implementation 'org.postgresql:postgresql:42.7.1' -// implementation 'com.fasterxml.jackson.core:jackson-databind' implementation 'org.neo4j.driver:neo4j-java-driver:5.14.0' +// implementation 'org.springframework.boot:spring-boot-starter-data-redis' // test testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.assertj:assertj-core:3.25.3' + testImplementation 'org.mockito:mockito-core' + testImplementation "org.mockito.kotlin:mockito-kotlin:5.2.1" + + // kotlin + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" // swaggerDoc implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/controller/RecommendActivityController.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/controller/RecommendActivityController.java index eb3606c..93d5309 100644 --- a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/controller/RecommendActivityController.java +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/controller/RecommendActivityController.java @@ -1,5 +1,8 @@ package com.example.silverbridgeX_user.RecommendActivity.controller; +import com.example.silverbridgeX_user.RecommendActivity.converter.RecommendActivityConverter; +import com.example.silverbridgeX_user.RecommendActivity.domain.RecommendActivity; +import com.example.silverbridgeX_user.RecommendActivity.dto.RecommendActivityResponseDto.RecommendActivityResDtos; import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; import com.example.silverbridgeX_user.global.api_payload.ApiResponse; import com.example.silverbridgeX_user.global.api_payload.SuccessCode; @@ -9,10 +12,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; -import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,33 +29,45 @@ public class RecommendActivityController { private final RecommendActivityService recommendActivityService; private final UserService userService; - @Operation(summary = "활동 선택", description = "로그 저장, selected 간선 생성합니다.") + @Operation(summary = "활동 반환", description = "추천하는 활동 리스트를 반환합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "ACTIVITY_2001", description = "선택한 활동 로그와 selected 간선 저장 완료했습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "ACTIVITY_2001", description = "추천하는 활동 리스를 반환 완료했습니다."), }) - @DeleteMapping("/select") - public ApiResponse select( - @RequestParam(name = "activityId") Long activityId, + @GetMapping("/recommend-activity") + public ApiResponse getRecommendActivities( @AuthenticationPrincipal CustomUserDetails customUserDetails ) { User user = userService.findByUserName(customUserDetails.getUsername()); - recommendActivityService.handleActivitySelection(user, activityId); - return ApiResponse.onSuccess(SuccessCode.USER_LOGOUT_SUCCESS, "로그 저장 완료"); + List recommendActivities = recommendActivityService.getRecommendActivities(user); + return ApiResponse.onSuccess(SuccessCode.RECOMMEND_ACTIVITY_VIEW_LIST_SUCCESS, + RecommendActivityConverter.recommendActivityResDtos(recommendActivities)); } - @Operation(summary = "활동 열람", description = "로그 저장합니다.") + @Operation(summary = "활동 선택", description = "활동 선택 로그를 저장, selected 간선 생성합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "ACTIVITY_2002", description = "열람한 활동 로그 저장 완료했습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "ACTIVITY_2002", description = "선택한 활동 로그와 selected 간선 저장 완료했습니다."), }) - @DeleteMapping("/view") - public ApiResponse view( - @RequestParam(name = "activityId") Long activityId, - @AuthenticationPrincipal CustomUserDetails customUserDetails + @PostMapping("/select") + public ApiResponse select( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "activityId") Long activityId ) { User user = userService.findByUserName(customUserDetails.getUsername()); - recommendActivityService.handleActivitySelection(user, activityId); + return ApiResponse.onSuccess(SuccessCode.RECOMMEND_ACTIVITY_SELECT_LOG_SUCCESS, "선택 로그 저장 완료"); + } - return ApiResponse.onSuccess(SuccessCode.USER_LOGOUT_SUCCESS, "로그 저장 완료"); + @Operation(summary = "활동 열람", description = "활동 열람 로그를 저장합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "ACTIVITY_2003", description = "열람한 활동 로그를 저장 완료했습니다."), + }) + @PostMapping("/view") + public ApiResponse view( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestParam(name = "activityId") Long activityId + ) { + User user = userService.findByUserName(customUserDetails.getUsername()); + recommendActivityService.handleActivityView(user, activityId); + return ApiResponse.onSuccess(SuccessCode.RECOMMEND_ACTIVITY_VIEW_LOG_SUCCESS, "열람 로그 저장 완료"); } } diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/converter/RecommendActivityConverter.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/converter/RecommendActivityConverter.java new file mode 100644 index 0000000..5106382 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/converter/RecommendActivityConverter.java @@ -0,0 +1,52 @@ +package com.example.silverbridgeX_user.RecommendActivity.converter; + +import com.example.silverbridgeX_user.RecommendActivity.domain.RecommendActivity; +import com.example.silverbridgeX_user.RecommendActivity.dto.RecommendActivityResponseDto.RecommendActivityResDto; +import com.example.silverbridgeX_user.RecommendActivity.dto.RecommendActivityResponseDto.RecommendActivityResDtos; +import com.example.silverbridgeX_user.activity.domain.Activity; +import com.example.silverbridgeX_user.user.domain.User; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +public class RecommendActivityConverter { + public static RecommendActivity saveRecommendActivity(LocalDate date, Integer number, User user, + Activity activity) { + return RecommendActivity.builder() + .date(date) + .user(user) + .number(number) + .activity(activity) + .build(); + } + + public static RecommendActivityResDto recommendActivityResDto(RecommendActivity recommendActivity) { + Activity activity = recommendActivity.getActivity(); + String address = + activity.getStreetAddress().isEmpty() ? activity.getLotNumberAddress() : activity.getStreetAddress(); + String content = activity.getEndDate().equals("2999-12-31") + ? (activity.getHomepageUrl() != null && !activity.getHomepageUrl().isBlank() + ? activity.getHomepageUrl() + : activity.getPhoneNumber()) + : activity.getStartDate() + " ~ " + activity.getEndDate(); + + return RecommendActivityResDto.builder() + .tag(String.valueOf(activity.getActivityType())) + .name(activity.getName()) + .address(address) + .content(content) + .build(); + } + + public static RecommendActivityResDtos recommendActivityResDtos(List activities) { + + List dtoList = activities.stream() + .map(RecommendActivityConverter::recommendActivityResDto) + .collect(Collectors.toList()); + + return RecommendActivityResDtos.builder() + .recommendActivityResDtos(dtoList) + .build(); + } + +} diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActionType.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActionType.java new file mode 100644 index 0000000..2d46196 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActionType.java @@ -0,0 +1,6 @@ +package com.example.silverbridgeX_user.RecommendActivity.domain; + +public enum ActionType { + VIEW, + SELECT +} diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActivityLog.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActivityLog.java new file mode 100644 index 0000000..545bea7 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/ActivityLog.java @@ -0,0 +1,47 @@ +package com.example.silverbridgeX_user.RecommendActivity.domain; + +import com.example.silverbridgeX_user.activity.domain.Activity; +import com.example.silverbridgeX_user.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "activity_log") +public class ActivityLog { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ActionType actionType; + + private LocalDateTime time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "activity_id") + private Activity activity; + +} diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/RecommendActivity.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/RecommendActivity.java index 72af850..dcb10c8 100644 --- a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/RecommendActivity.java +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/domain/RecommendActivity.java @@ -1,5 +1,6 @@ package com.example.silverbridgeX_user.RecommendActivity.domain; +import com.example.silverbridgeX_user.activity.domain.Activity; import com.example.silverbridgeX_user.user.domain.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -34,6 +35,6 @@ public class RecommendActivity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "activity_id") - private User activity; + private Activity activity; } diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/dto/RecommendActivityResponseDto.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/dto/RecommendActivityResponseDto.java new file mode 100644 index 0000000..775d79b --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/dto/RecommendActivityResponseDto.java @@ -0,0 +1,45 @@ +package com.example.silverbridgeX_user.RecommendActivity.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +public class RecommendActivityResponseDto { + + @Schema(description = "RecommendActivityResDtos") + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class RecommendActivityResDtos { + @Schema(description = "추천 활동 리스트") + private List recommendActivityResDtos; + } + + @Schema(description = "RecommendActivityResDto") + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class RecommendActivityResDto { + @Schema(description = "태그") + private String tag; + + @Schema(description = "이름") + private String name; + + @Schema(description = "주소") + private String address; + + @Schema(description = "기간/링크/전화번호") + private String content; + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/repository/RecommendActivityRepository.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/repository/RecommendActivityRepository.java index 14877d0..a88f2b9 100644 --- a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/repository/RecommendActivityRepository.java +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/repository/RecommendActivityRepository.java @@ -1,8 +1,12 @@ package com.example.silverbridgeX_user.RecommendActivity.repository; import com.example.silverbridgeX_user.RecommendActivity.domain.RecommendActivity; +import com.example.silverbridgeX_user.user.domain.User; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface RecommendActivityRepository extends JpaRepository { + List findAllByUser(User user); + void deleteByUser(User user); } diff --git a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/service/RecommendActivityService.java b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/service/RecommendActivityService.java index 7956a04..ef47c35 100644 --- a/src/main/java/com/example/silverbridgeX_user/RecommendActivity/service/RecommendActivityService.java +++ b/src/main/java/com/example/silverbridgeX_user/RecommendActivity/service/RecommendActivityService.java @@ -1,14 +1,21 @@ package com.example.silverbridgeX_user.RecommendActivity.service; +import com.example.silverbridgeX_user.RecommendActivity.converter.RecommendActivityConverter; +import com.example.silverbridgeX_user.RecommendActivity.domain.ActivityLog; +import com.example.silverbridgeX_user.RecommendActivity.domain.RecommendActivity; +import com.example.silverbridgeX_user.RecommendActivity.repository.RecommendActivityRepository; import com.example.silverbridgeX_user.activity.converter.ActivityLogConverter; import com.example.silverbridgeX_user.activity.domain.Activity; -import com.example.silverbridgeX_user.activity.domain.ActivityLog; import com.example.silverbridgeX_user.activity.repository.ActivityLogRepository; +import com.example.silverbridgeX_user.activity.repository.ActivityRankingRepository; import com.example.silverbridgeX_user.activity.repository.ActivityRepository; import com.example.silverbridgeX_user.global.api_payload.ErrorCode; import com.example.silverbridgeX_user.global.exception.GeneralException; import com.example.silverbridgeX_user.user.domain.User; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,42 +29,195 @@ public class RecommendActivityService { private final ActivityLogRepository activityLogRepository; private final ActivityRepository activityRepository; - private final Driver neo4jDriver; // Neo4j Java Driver + private final RecommendActivityRepository recommendActivityRepository; + private final ActivityRankingRepository activityRankingRepository; + private final Driver neo4jDriver; - public void handleActivitySelection(User user, Long activityId){ + public List getRecommendActivities(User user) { + return recommendActivityRepository.findAllByUser(user); + } + + public void handleActivitySelection(User user, Long activityId) { LocalDateTime now = LocalDateTime.now(); // 1. 로그 저장 Activity activity = activityRepository.findById(activityId) .orElseThrow(() -> GeneralException.of(ErrorCode.ACTIVITY_NOT_FOUND)); - ActivityLog log = ActivityLogConverter.saveActivityLog(user, activity, now, "SELECT"); - activityLogRepository.save(log); + ActivityLog activityLog = ActivityLogConverter.saveActivityLog(user, activity, now, "SELECT"); + activityLogRepository.save(activityLog); // 2. 간선 갱신 try (Session session = neo4jDriver.session()) { session.writeTransaction(tx -> { // 기존 :PREFERRED 제거 tx.run(""" - MATCH (u:User {id: $uid})-[r:PREFERRED]->(a:Activity {id: $aid}) - DELETE r - """, - Map.of("uid", user.getId().toString(), "aid", "act" + activity.getId())); + MATCH (u:User {id: $uid})-[r:PREFERRED]->(a:Activity {id: $aid}) + DELETE r + """, + Map.of("uid", user.getId(), "aid", activity.getId())); // :SELECTED 생성 tx.run(""" - MATCH (u:User {id: $uid}), (a:Activity {id: $aid}) - MERGE (u)-[:SELECTED]->(a) - """, - Map.of("uid", user.getId().toString(), "aid", "act" + activity.getId())); + MATCH (u:User {id: $uid}), (a:Activity {id: $aid}) + MERGE (u)-[:SELECTED]->(a) + """, + Map.of("uid", user.getId(), "aid", activity.getId())); + + // 생성됐는지 확인 + var checkResult = tx.run(""" + MATCH (u:User {id: $uid})-[r:SELECTED]->(a:Activity {id: $aid}) + RETURN COUNT(r) > 0 AS exists + """, + Map.of("uid", user.getId(), "aid", activity.getId())); + + boolean exists = checkResult.single().get("exists").asBoolean(); + if (!exists) { + log.error("사용자 활동 SELECT 간선이 생기지 않았습니다."); + } return null; }); } } - public void handleActivityView(User user, Long activityId, LocalDateTime now){ + public void handleActivityView(User user, Long activityId) { + LocalDateTime now = LocalDateTime.now(); Activity activity = activityRepository.findById(activityId) .orElseThrow(() -> GeneralException.of(ErrorCode.ACTIVITY_NOT_FOUND)); ActivityLog log = ActivityLogConverter.saveActivityLog(user, activity, now, "VIEW"); activityLogRepository.save(log); } + + public void execWrite(String cypher) { + try (Session s = neo4jDriver.session()) { + s.writeTransaction(tx -> { + tx.run(cypher); + return null; + }); + } + } + + /** + * :PREFERRED 간선 베치 삽입 + */ + public void insertPreferredEdges(List> rows) { + try (var ses = neo4jDriver.session()) { + ses.writeTransaction(tx -> { + for (var r : rows) { + Long userId = ((Number) r.get("user_id")).longValue(); + Long activityId = ((Number) r.get("activity_id")).longValue(); + Double score = ((Number) r.get("score")).doubleValue(); + + tx.run( + "MATCH (u:User {id: $uid}), (a:Activity {id: $aid}) " + + "MERGE (u)-[:PREFERRED {score: $score}]->(a)", + Map.of( + "uid", userId, + "aid", activityId, + "score", score + ) + ); + } + return null; + }); + } + } + + + /** + * :SIMILAR 간선 베치 삽입 + */ + public void insertSimilarEdges(List> rows) { + try (var ses = neo4jDriver.session()) { + ses.writeTransaction(tx -> { + for (var r : rows) { + Long uid1 = ((Number) r.get("uid1")).longValue(); + Long uid2 = ((Number) r.get("uid2")).longValue(); + Double wSim = ((Number) r.get("w_sim")).doubleValue(); + + tx.run(""" + MATCH (u1:User {id: $uid1}), (u2:User {id: $uid2}) + MERGE (u1)-[rel:SIMILAR]-(u2) + SET rel.score = $w_sim + """, Map.of( + "uid1", uid1, + "uid2", uid2, + "w_sim", wSim + )); + } + return null; + }); + } + } + + /** + * 하루에 한 번 실행 – prefer 14 + 인기 4 + 랜덤 2 + */ + public void generateAllUsersRecommendation() { + + // 0) 모든 사용자 조회 (PostgreSQL) + List users = activityRepository.findAllUsers(); + + try (Session session = neo4jDriver.session()) { + + for (User user : users) { + + /* ---------- 1. PREFERRED 상위 10 & SIMILAR 사용자 SELECT 상위 N ---------- */ + List preferredIds = session.readTransaction(tx -> tx.run(""" + MATCH (u:User {id:$uid})-[r:PREFERRED]->(a:Activity) + RETURN a.id AS aid + ORDER BY r.score DESC + LIMIT 10 + """, Map.of("uid", user.getId())) + .list(r -> r.get("aid").asLong())); + + // SIMILAR 사용자들의 SELECT 상위(클릭 많은) 활동 + List similarTopIds = session.readTransaction(tx -> tx.run(""" + MATCH (u:User {id:$uid})-[:SIMILAR]-(s:User)-[:SELECTED]->(a:Activity) + WITH a, count(*) AS freq + ORDER BY freq DESC + RETURN a.id AS aid + LIMIT 14 + """, Map.of("uid", user.getId())) + .list(r -> r.get("aid").asLong())); + + LinkedHashSet resultIds = new LinkedHashSet<>(); + resultIds.addAll(preferredIds); + for (Long id : similarTopIds) { + if (resultIds.size() >= 14) { + break; + } + resultIds.add(id); + } + + /* ---------- 2. 인기 랭킹 + 거리 필터 ---------- */ + List popularIds = activityRankingRepository.fetchTopByDistance( + Float.parseFloat(user.getLatitude()), Float.parseFloat(user.getLongitude()), 18, resultIds); + + for (Long id : popularIds) { + if (resultIds.size() >= 18) { + break; + } + resultIds.add(id); + } + + /* ---------- 3. 무작위 2 ---------- */ + List randomIds = activityRepository.pickRandom(2, resultIds); + resultIds.addAll(randomIds); + + + /* ---------- 4. 추천 리스트 저장 ---------- */ + recommendActivityRepository.deleteByUser(user); // 기존 추천 삭제 + + int order = 0; + for (Long aid : resultIds) { + LocalDate today = LocalDate.now(); + Activity activity = activityRepository.findById(aid) + .orElseThrow(() -> GeneralException.of(ErrorCode.ACTIVITY_NOT_FOUND)); + RecommendActivity recommendActivity + = RecommendActivityConverter.saveRecommendActivity(today, order++, user, activity); + recommendActivityRepository.save(recommendActivity); + } + } + } + } } diff --git a/src/main/java/com/example/silverbridgeX_user/SilverbridgeXUserApplication.java b/src/main/java/com/example/silverbridgeX_user/SilverbridgeXUserApplication.java index b3b6701..23fdf6a 100644 --- a/src/main/java/com/example/silverbridgeX_user/SilverbridgeXUserApplication.java +++ b/src/main/java/com/example/silverbridgeX_user/SilverbridgeXUserApplication.java @@ -1,13 +1,18 @@ package com.example.silverbridgeX_user; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + @SpringBootApplication +@EnableBatchProcessing +@EnableScheduling public class SilverbridgeXUserApplication { - public static void main(String[] args) { - SpringApplication.run(SilverbridgeXUserApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(SilverbridgeXUserApplication.class, args); + } } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/converter/ActivityLogConverter.java b/src/main/java/com/example/silverbridgeX_user/activity/converter/ActivityLogConverter.java index 0cd2ab5..df54aff 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/converter/ActivityLogConverter.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/converter/ActivityLogConverter.java @@ -1,21 +1,18 @@ package com.example.silverbridgeX_user.activity.converter; -import com.example.silverbridgeX_user.activity.domain.ActionType; +import com.example.silverbridgeX_user.RecommendActivity.domain.ActionType; +import com.example.silverbridgeX_user.RecommendActivity.domain.ActivityLog; import com.example.silverbridgeX_user.activity.domain.Activity; -import com.example.silverbridgeX_user.activity.domain.ActivityLog; import com.example.silverbridgeX_user.user.domain.User; -import com.example.silverbridgeX_user.user.dto.UserRequestDto; import java.time.LocalDateTime; import java.util.Objects; -import lombok.NoArgsConstructor; -@NoArgsConstructor public class ActivityLogConverter { public static ActivityLog saveActivityLog(User user, Activity activity, LocalDateTime now, String type) { ActionType actionType = null; - if(Objects.equals(type, "View")){ + if (Objects.equals(type, "VIEW")) { actionType = ActionType.VIEW; - }else if(Objects.equals(type, "SELECT")){ + } else if (Objects.equals(type, "SELECT")) { actionType = ActionType.SELECT; } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActionType.java b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActionType.java deleted file mode 100644 index d889c8d..0000000 --- a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActionType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.silverbridgeX_user.activity.domain; - -public enum ActionType { - VIEW, - SELECT -} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/domain/Activity.java b/src/main/java/com/example/silverbridgeX_user/activity/domain/Activity.java index e8f41ab..3a83503 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/domain/Activity.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/domain/Activity.java @@ -2,10 +2,11 @@ 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.Transient; import java.time.LocalDate; import lombok.AllArgsConstructor; import lombok.Builder; @@ -25,12 +26,14 @@ public class Activity { @Column(unique = true) private String name; + @Enumerated(EnumType.STRING) + @Column(nullable = false) private ActivityType activityType; + @Column(length = 1000) private String description; - @Column(columnDefinition = "vector(384)") - @Transient // JPA 저장 시 무시 + @Column(name = "description_embedding", columnDefinition = "vector(384)", insertable = false, updatable = false) private String descriptionEmbedding; private String streetAddress; @@ -49,10 +52,23 @@ public class Activity { private String phoneNumber; - private Long chosen; + private Long clickNum; - private Long shown; + private Long impressionNum; - private Double CTR; + private Double ctr; + + public void updateCoordinate(String longitude, String latitude) { + this.longitude = longitude; + this.latitude = latitude; + } + + public void updateLongitude(String longitude) { + this.longitude = longitude; + } + + public void updateLatitude(String latitude) { + this.latitude = latitude; + } } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityLog.java b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityLog.java deleted file mode 100644 index a223e3e..0000000 --- a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityLog.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.example.silverbridgeX_user.activity.domain; - -import com.example.silverbridgeX_user.user.domain.User; -import jakarta.persistence.*; -import java.time.LocalDateTime; -import lombok.*; - -@Entity -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "activity_log") -public class ActivityLog { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - private ActionType actionType; - - private LocalDateTime time; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "activity_id") - private Activity activity; - -} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityRanking.java b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityRanking.java new file mode 100644 index 0000000..b0140f5 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityRanking.java @@ -0,0 +1,35 @@ +package com.example.silverbridgeX_user.activity.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import java.time.LocalDate; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ActivityRanking { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "activity_id") + private Activity activity; + + private Integer rank; + + @CreationTimestamp + private LocalDate createdAt; +} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityType.java b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityType.java index 66a395f..913e028 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityType.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/domain/ActivityType.java @@ -2,5 +2,6 @@ public enum ActivityType { FESTIVAL, + TOUR_SPOT } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/dto/ActivityResponseDto.java b/src/main/java/com/example/silverbridgeX_user/activity/dto/ActivityResponseDto.java new file mode 100644 index 0000000..71eb107 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/activity/dto/ActivityResponseDto.java @@ -0,0 +1,148 @@ +package com.example.silverbridgeX_user.activity.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +public class ActivityResponseDto { + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class FestivalItemDto { + @JsonProperty("fstvlNm") + private String name; // 축제명 + + @JsonProperty("fstvlCo") + private String description; // 축제 내용 + + @JsonProperty("rdnmadr") + private String streetAddress; // 도로명주소 + + @JsonProperty("lnmadr") + private String lotNumberAddress; // 지번 주소 + + @JsonProperty("latitude") + private String latitude; // 위도 + + @JsonProperty("longitude") + private String longitude; // 경도 + + @JsonProperty("homepageUrl") + private String homepageUrl; // 홈페이지 주소 + + @JsonProperty("phoneNumber") + private String phoneNumber; // 전화번호 + + @JsonProperty("relateInfo") + private String relateInfo; // 관련 정보 + + @JsonProperty("fstvlStartDate") + private String fstvlStartDate; // 축제 시작 일자 + + @JsonProperty("fstvlEndDate") + private String fstvlEndDate; // 축제 종료 일자 + + @JsonProperty("mnnstNm") + private String hostOrg1; // 주관기관명 + + @JsonProperty("auspcInsttNm") + private String hostOrg2; // 후최기관명 + + @JsonProperty("suprtInsttNm") + private String support; // 후원기관명 + + @JsonProperty("referenceDate") + private String referenceDate; // 데이터 기준 일자 + + @JsonProperty("insttCode") + private String insttCode; // 제공기관 코드 + + @JsonProperty("insttNm") + private String insttNm; // 제공기관 기관병 + + @JsonProperty("opar") + private String opar; // 개최장소 + + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class TourSpotItemDto { + @JsonProperty("trrsrtNm") + private String name; // 관광지명 + + @JsonProperty("trrsrtIntrcn") + private String description; // 관광지 소개 + + @JsonProperty("rdnmadr") + private String streetAddress; // 도로명 주소 + + @JsonProperty("lnmadr") + private String lotNumberAddress; // 지번 주소 + + @JsonProperty("latitude") + private String latitude; // 위도 + + @JsonProperty("longitude") + private String longitude; // 경도 + + @JsonProperty("ar") + private String area; // 면적 + + @JsonProperty("trrsrtSe") + private String classification; // 관광지 구분 + + @JsonProperty("phoneNumber") + private String phoneNumber; // 관리기관 전화번호 + + @JsonProperty("institutionNm") + private String institutionNm; // 관리기관명 + + @JsonProperty("insttNm") + private String insttNm; // 관리기관명 + + @JsonProperty("referenceDate") + private String referenceDate; // 데이터 기준 일자 + + @JsonProperty("insttCode") + private String insttCode; // 제공기관 코드 + + @JsonProperty("cnvnncFclty") + private String cnvnncFclty; // 공공 편익 시설 정보 + + @JsonProperty("recrtClturFclty") + private String recrtClturFclty; // 휴양 및 문화시설 정보 + + @JsonProperty("stayngInfo") + private String stayngInfo; // 숙박 시설 정보 + + @JsonProperty("mvmAmsmtFclty") + private String mvmAmsmtFclty; // 운동 및 오락시설 정보 + + @JsonProperty("hospitalityFclty") + private String hospitalityFclty; // 접객 시설 정보 + + @JsonProperty("sportFclty") + private String sportFclty; // 지원 시설 정보 + + @JsonProperty("appnDate") + private String appnDate; // 지정 일자 + + @JsonProperty("aceptncCo") + private String aceptncCo; // 수용 인원 수 + + @JsonProperty("prkplceCo") + private String prkplceCo; // 주차 가능 수 + } + +} \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/activity/dto/FestivalResponseDto.java b/src/main/java/com/example/silverbridgeX_user/activity/dto/FestivalResponseDto.java deleted file mode 100644 index a8b9b2a..0000000 --- a/src/main/java/com/example/silverbridgeX_user/activity/dto/FestivalResponseDto.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.example.silverbridgeX_user.activity.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import lombok.ToString; - -@Getter -@NoArgsConstructor -@ToString -public class FestivalResponseDto { - - @Getter - @Setter - @NoArgsConstructor - @AllArgsConstructor - @ToString - public static class FestivalItemDto { - @JsonProperty("fstvlNm") - private String name; - - @JsonProperty("fstvlCo") - private String description; - - @JsonProperty("rdnmadr") - private String streetAddress; - - @JsonProperty("latitude") - private String latitude; - - @JsonProperty("longitude") - private String longitude; - - @JsonProperty("homepageUrl") - private String homepageUrl; - - @JsonProperty("phoneNumber") - private String phoneNumber; - - @JsonProperty("fstvlStartDate") - private String fstvlStartDate; - - @JsonProperty("fstvlEndDate") - private String fstvlEndDate; - - @JsonProperty("mnnstNm") - private String hostOrg1; - - @JsonProperty("auspcInsttNm") - private String hostOrg2; - - @JsonProperty("suprtInsttNm") - private String support; - - @JsonProperty("relateInfo") - private String relateInfo; - - @JsonProperty("lnmadr") - private String lotNumberAddress; - - @JsonProperty("referenceDate") - private String referenceDate; - - @JsonProperty("insttCode") - private String insttCode; - - @JsonProperty("insttNm") - private String insttNm; - - @JsonProperty("opar") - private String opar; - - } -} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityLogRepository.java b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityLogRepository.java index de014b4..4185810 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityLogRepository.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityLogRepository.java @@ -1,6 +1,6 @@ package com.example.silverbridgeX_user.activity.repository; -import com.example.silverbridgeX_user.activity.domain.ActivityLog; +import com.example.silverbridgeX_user.RecommendActivity.domain.ActivityLog; import org.springframework.data.jpa.repository.JpaRepository; public interface ActivityLogRepository extends JpaRepository { diff --git a/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRankingRepository.java b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRankingRepository.java new file mode 100644 index 0000000..4fae27c --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRankingRepository.java @@ -0,0 +1,28 @@ +package com.example.silverbridgeX_user.activity.repository; + +import com.example.silverbridgeX_user.activity.domain.ActivityRanking; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface ActivityRankingRepository extends JpaRepository { + /** + * 사용자 위치로부터 10 km 이내 인기 rank 상위 N 반환 + */ + @Query(value = """ + SELECT ar.activity_id + FROM activity_ranking ar + JOIN activity a ON a.id = ar.activity_id + WHERE earth_distance( + ll_to_earth(CAST(:lat AS float), CAST(:lon AS float)), + ll_to_earth(CAST(a.latitude AS float), CAST(a.longitude AS float)) + ) < 10000 + AND ar.activity_id NOT IN (:excluded) + ORDER BY ar.rank ASC + LIMIT :limit + """, nativeQuery = true) + List fetchTopByDistance(double lat, double lon, + int limit, Collection excluded); + +} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRepository.java b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRepository.java index 76320f5..ff97c2e 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRepository.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/repository/ActivityRepository.java @@ -1,8 +1,24 @@ package com.example.silverbridgeX_user.activity.repository; import com.example.silverbridgeX_user.activity.domain.Activity; +import com.example.silverbridgeX_user.user.domain.User; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ActivityRepository extends JpaRepository { boolean existsByName(String name); // 중복 방지 + + @Query(value = """ + SELECT id FROM activity + WHERE id NOT IN (:excluded) + ORDER BY random() + LIMIT :limit + """, nativeQuery = true) + List pickRandom(int limit, Collection excluded); + + @Query("select u from User u") + List findAllUsers(); + } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityApiService.java b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityApiService.java new file mode 100644 index 0000000..8e81a5b --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityApiService.java @@ -0,0 +1,64 @@ +package com.example.silverbridgeX_user.activity.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ActivityApiService { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${activity.api.key.festival}") + private String apiKey; + + public String buildUrl(String url, int pageNo) throws Exception { + StringBuilder urlBuilder = new StringBuilder(url); + urlBuilder.append("?" + URLEncoder.encode("serviceKey", "UTF-8") + "=" + URLEncoder.encode(apiKey, + StandardCharsets.UTF_8)); + urlBuilder.append("&type=json&pageNo=" + pageNo + "&numOfRows=50"); + return urlBuilder.toString(); + } + + public String getJsonFromUrl(String urlStr) throws Exception { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Content-type", "application/json"); + + BufferedReader rd = (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) + ? new BufferedReader(new InputStreamReader(conn.getInputStream())) + : new BufferedReader(new InputStreamReader(conn.getErrorStream())); + + StringBuilder sb = new StringBuilder(); + String line; + while ((line = rd.readLine()) != null) { + sb.append(line); + } + rd.close(); + conn.disconnect(); + + return sb.toString(); + } + + public JsonNode parseItems(String json) throws Exception { + return objectMapper.readTree(json) + .path("response") + .path("body") + .path("items"); + } + + public JsonNode parsePoint(String json) throws Exception { + return objectMapper.readTree(json) + .path("response") + .path("result") + .path("point"); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityInserter.java b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityInserter.java new file mode 100644 index 0000000..ad8f05a --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityInserter.java @@ -0,0 +1,22 @@ +package com.example.silverbridgeX_user.activity.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ActivityInserter implements ApplicationRunner { + private final ActivityService activityService; + + @Override + public void run(ApplicationArguments args) throws Exception { +// System.out.println("🌟 ApplicationRunner로 실행 중"); +// activityService.fetchAndSaveFestivalData(); +// activityService.fetchAndSaveTourSpotData(); +// activityService.fetchAndSaveCoordinate(); +// activityService.insertActivitiesNeo4j(); +// System.out.println("insert finish"); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityService.java b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityService.java index b63a07e..aa407e7 100644 --- a/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityService.java +++ b/src/main/java/com/example/silverbridgeX_user/activity/service/ActivityService.java @@ -1,29 +1,317 @@ package com.example.silverbridgeX_user.activity.service; -import com.example.silverbridgeX_user.activity.converter.ActivityLogConverter; import com.example.silverbridgeX_user.activity.domain.Activity; -import com.example.silverbridgeX_user.activity.domain.ActivityLog; -import com.example.silverbridgeX_user.activity.repository.ActivityLogRepository; +import com.example.silverbridgeX_user.activity.domain.ActivityType; +import com.example.silverbridgeX_user.activity.dto.ActivityResponseDto.FestivalItemDto; +import com.example.silverbridgeX_user.activity.dto.ActivityResponseDto.TourSpotItemDto; +import com.example.silverbridgeX_user.activity.repository.ActivityNativeRepository; import com.example.silverbridgeX_user.activity.repository.ActivityRepository; import com.example.silverbridgeX_user.global.api_payload.ErrorCode; import com.example.silverbridgeX_user.global.exception.GeneralException; -import com.example.silverbridgeX_user.user.converter.UserConverter; -import com.example.silverbridgeX_user.user.domain.User; -import java.time.LocalDateTime; -import java.util.Map; -import java.util.Optional; +import com.example.silverbridgeX_user.global.util.EmbeddingClient; +import com.example.silverbridgeX_user.user.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.transaction.Transactional; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.neo4j.driver.Driver; import org.neo4j.driver.Session; +import org.neo4j.driver.Values; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor public class ActivityService { + private final ActivityRepository activityRepository; + private final EmbeddingClient embeddingClient; + private final ActivityNativeRepository activityNativeRepository; + private final ActivityApiService activityApiService; + private final UserRepository userRepository; + private final Driver driver; + private final ObjectMapper objectMapper = new ObjectMapper(); + String festivalApiUrl = "http://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api"; + String tourSpotApiUrl = "http://api.data.go.kr/openapi/tn_pubr_public_trrsrt_api"; + String addressToCoordinateApiurl = "https://api.vworld.kr/req/address?service=address&format=json&request=getCoord&refine=false"; + public void fetchAndSaveFestivalData() { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + int page = 1; + while (true) { + try { + String urlStr = activityApiService.buildUrl(festivalApiUrl, page); + String json = activityApiService.getJsonFromUrl(urlStr); + JsonNode items = activityApiService.parseItems(json); + + if (items == null || !items.isArray() || items.isEmpty()) { + log.info("page {} 응답 없음. 중단.", page); + return; + } + + executor.submit(() -> { + for (JsonNode item : items) { + try { + FestivalItemDto dto = objectMapper.treeToValue(item, FestivalItemDto.class); + + if (LocalDate.parse(dto.getFstvlEndDate()).isBefore(LocalDate.now())) { + continue; + } + + if (!activityRepository.existsByName(dto.getName())) { + Activity activity = Activity.builder() + .name(dto.getName()) + .description(dto.getDescription()) + .streetAddress(dto.getStreetAddress()) + .lotNumberAddress(dto.getLotNumberAddress()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .startDate(LocalDate.parse(dto.getFstvlStartDate())) + .endDate(LocalDate.parse(dto.getFstvlEndDate())) + .homepageUrl(dto.getHomepageUrl()) + .phoneNumber(dto.getPhoneNumber()) + .activityType(ActivityType.FESTIVAL) + .clickNum(0L) + .impressionNum(0L) + .ctr(0.0) + .build(); + activityRepository.save(activity); + + String vectorLiteral = embeddingClient.getEmbeddingLiteral(dto.getDescription()); + activityNativeRepository.updateVectorByName(dto.getName(), vectorLiteral); + + log.info("저장됨: " + dto.getName()); + } + + } catch (Exception e) { + log.error("항목 처리 중 오류 발생", e); + } + } + }); + page++; + Thread.sleep(100); // 속도 제한 + + } catch (Exception e) { + log.error("page {} 처리 중 오류 발생", page, e); + break; + } + } + } + + public void fetchAndSaveTourSpotData() { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + + int page = 1; + while (true) { + try { + String urlStr = activityApiService.buildUrl(tourSpotApiUrl, page); + String json = activityApiService.getJsonFromUrl(urlStr); + JsonNode items = activityApiService.parseItems(json); + + if (items == null || !items.isArray() || items.isEmpty()) { + log.info("page {} 응답 없음. 중단.", page); + return; + } + + executor.submit(() -> { + for (JsonNode item : items) { + try { + TourSpotItemDto dto = objectMapper.treeToValue(item, TourSpotItemDto.class); + + if (!activityRepository.existsByName(dto.getName())) { + Activity activity = Activity.builder() + .name(dto.getName()) + .description(dto.getDescription()) + .streetAddress(dto.getStreetAddress()) + .lotNumberAddress(dto.getLotNumberAddress()) + .latitude(dto.getLatitude()) + .longitude(dto.getLongitude()) + .startDate(LocalDate.parse(dto.getReferenceDate())) + .endDate(LocalDate.parse("2999-12-31")) + .phoneNumber(dto.getPhoneNumber()) + .activityType(ActivityType.TOUR_SPOT) + .clickNum(0L) + .impressionNum(0L) + .ctr(0.0) + .build(); + activityRepository.save(activity); + + String vectorLiteral = embeddingClient.getEmbeddingLiteral(dto.getDescription()); + activityNativeRepository.updateVectorByName(dto.getName(), vectorLiteral); + + log.info("저장됨: {}", dto.getName()); + } + } catch (Exception e) { + log.error("항목 처리 중 오류 발생", e); + } + } + }); + page++; + Thread.sleep(100); // 속도 제한 + + } catch (Exception e) { + log.error("page {} 처리 중 오류 발생", page, e); + break; + } + } + } + + public void fetchAndSaveCoordinate() throws Exception { + List activities = activityRepository.findAll(); + System.out.println(activities.size()); + + for (Activity activity : activities) { + + try { + if (activity.getLatitude().startsWith("\"")) { + change(activity); + } + + if (!activity.getLongitude().isEmpty() || !activity.getLatitude().isEmpty()) { + continue; + } + + String urlStr = null; + + if (activity.getStreetAddress() != null) { + urlStr = buildCoordinateUrl("ROAD", activity.getStreetAddress()); + } else if (activity.getLotNumberAddress() != null) { + urlStr = buildCoordinateUrl("PARCEL", activity.getLotNumberAddress()); + } + + if (urlStr == null) { + continue; + } + + String json = activityApiService.getJsonFromUrl(urlStr); + log.info(json); + JsonNode point = activityApiService.parsePoint(json); + + if (!point.isMissingNode()) { + String x = String.valueOf(point.get("x")); + String y = String.valueOf(point.get("y")); + + activity.updateCoordinate(x, y); + activityRepository.save(activity); + } + } catch (IOException | ParseException e) { + throw new RuntimeException(e); + } + + } + + } + + @Value("${geocoder.api.key}") + private String key; + + private String buildCoordinateUrl(String type, String address) throws Exception { + StringBuilder urlBuilder = new StringBuilder(addressToCoordinateApiurl); + urlBuilder.append("&key=" + key); + urlBuilder.append("&type=" + type); // PARCEL : 지번주소, ROAD : 도로명주소 + urlBuilder.append("&address=" + URLEncoder.encode(address, StandardCharsets.UTF_8)); + return urlBuilder.toString(); + } + + private void change(Activity activity) { + String latitude = activity.getLatitude(); + String longitude = activity.getLongitude(); + + boolean updated = false; + + if (latitude != null && latitude.contains("\"")) { + latitude = latitude.replace("\"", ""); // 모든 " 제거 + activity.updateLatitude(latitude); + updated = true; + } + + if (longitude != null && longitude.contains("\"")) { + longitude = longitude.replace("\"", ""); + activity.updateLongitude(longitude); + updated = true; + } + + if (updated) { + activityRepository.save(activity); + System.out.println("좌표 문자열 정제 완료: " + activity.getId()); + } + } + + // 해당 API에서 제공하지 않는 주소들에 대한 2차 검증 로직 + public void test() throws Exception { + Activity activity = activityRepository.findById(Long.valueOf(102)) + .orElseThrow(() -> GeneralException.of(ErrorCode.ACTIVITY_NOT_FOUND)); + String urlStr = buildCoordinateUrl("PARCEL", activity.getLotNumberAddress()); + String json = activityApiService.getJsonFromUrl(urlStr); + System.out.println(json); + + } + + public void insertActivitiesNeo4j() { + List activities = activityRepository.findAll(); + + for (Activity activity : activities) { + List embeddingVector = parseEmbeddingVector(activity.getDescriptionEmbedding()); + String description = activity.getDescription().length() > 100 + ? activity.getDescription().substring(0, 100) + : activity.getDescription(); + + try (Session session = driver.session()) { + session.writeTransaction(tx -> { + tx.run(""" + MERGE (a:Activity {id: $id}) + SET a.name = $name, + a.description = $description, + a.latitude = $latitude, + a.longitude = $longitude, + a.startDate = $startDate, + a.endDate = $endDate + """, + Values.parameters( + "id", activity.getId(), + "name", activity.getName(), + "description", description, + "latitude", activity.getLatitude(), + "longitude", activity.getLongitude(), + "startDate", activity.getStartDate(), + "endDate", activity.getEndDate() + )); + return null; + }); + } + } + } + + private List parseEmbeddingVector(String str) { + if (str == null || str.isBlank()) { + return List.of(); + } + + str = str.replaceAll("[\\[\\]]", ""); + String[] tokens = str.split(","); + List result = new ArrayList<>(); + for (String token : tokens) { + result.add(Float.parseFloat(token.trim())); + } + return result; + } + + @Transactional + public void updateActivityEmbedding() { + System.out.println("startttt"); + System.out.println(userRepository.updateActivityEmbeddingAvgForUsers()); + } } diff --git a/src/main/java/com/example/silverbridgeX_user/activity/service/FestivalApiService.java b/src/main/java/com/example/silverbridgeX_user/activity/service/FestivalApiService.java deleted file mode 100644 index 004ccf3..0000000 --- a/src/main/java/com/example/silverbridgeX_user/activity/service/FestivalApiService.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.example.silverbridgeX_user.activity.service; - -import com.example.silverbridgeX_user.activity.domain.Activity; -import com.example.silverbridgeX_user.activity.domain.ActivityType; -import com.example.silverbridgeX_user.activity.dto.FestivalResponseDto.FestivalItemDto; -import com.example.silverbridgeX_user.activity.repository.ActivityNativeRepository; -import com.example.silverbridgeX_user.activity.repository.ActivityRepository; -import com.example.silverbridgeX_user.global.util.EmbeddingClient; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.LocalDate; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FestivalApiService { - - private final ActivityRepository activityRepository; - private final EmbeddingClient embeddingClient; - private final ActivityNativeRepository activityNativeRepository; - - @Value("${activity.api.key.festival}") - private String apiKey; - - public void fetchAndSaveFestivalData() throws Exception { - System.out.println("start"); - StringBuilder urlBuilder = new StringBuilder("http://api.data.go.kr/openapi/tn_pubr_public_cltur_fstvl_api"); - urlBuilder.append("?" + URLEncoder.encode("serviceKey", "UTF-8") - + "=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8)); - urlBuilder.append("&type=json&pageNo=1&numOfRows=10"); - - URL url = new URL(urlBuilder.toString()); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setRequestProperty("Content-type", "application/json"); - - BufferedReader rd = (conn.getResponseCode() >= 200 && conn.getResponseCode() <= 300) - ? new BufferedReader(new InputStreamReader(conn.getInputStream())) - : new BufferedReader(new InputStreamReader(conn.getErrorStream())); - - StringBuilder sb = new StringBuilder(); - String line; - while ((line = rd.readLine()) != null) { - sb.append(line); - } - rd.close(); - conn.disconnect(); - - log.info("Raw JSON 응답:\n" + sb.toString()); - - ObjectMapper objectMapper = new ObjectMapper(); - JsonNode items = objectMapper.readTree(sb.toString()) - .path("response") - .path("body") - .path("items"); - - if (items != null && items.isArray()) { - System.out.println("가져온 축제 개수: " + items.size()); - for (JsonNode item : items) { - FestivalItemDto dto = objectMapper.treeToValue(item, FestivalItemDto.class); - - if (LocalDate.parse(dto.getFstvlEndDate()).isBefore(LocalDate.now())) { - continue; - } - - if (!activityRepository.existsByName(dto.getName())) { - Activity activity = Activity.builder() - .name(dto.getName()) - .description(dto.getDescription()) - .streetAddress(dto.getStreetAddress()) - .lotNumberAddress(dto.getLotNumberAddress()) - .latitude(dto.getLatitude()) - .longitude(dto.getLongitude()) - .startDate(LocalDate.parse(dto.getFstvlStartDate())) - .endDate(LocalDate.parse(dto.getFstvlEndDate())) - .homepageUrl(dto.getHomepageUrl()) - .phoneNumber(dto.getPhoneNumber()) - .activityType(ActivityType.FESTIVAL) - .chosen(0L) - .shown(0L) - .CTR(0.0) - .build(); - activityRepository.save(activity); - - String vectorLiteral = embeddingClient.getEmbeddingLiteral( - dto.getDescription()); // "(0.1, 0.2, ..., 0.3)" - activityNativeRepository.updateVectorByName( - dto.getName(), - vectorLiteral - ); - - log.info("저장됨: " + dto.getName()); - } - - - } - } else { - log.info("items가 null이거나 배열이 아님. 실제 응답 확인 필요"); - } - } -} - diff --git a/src/main/java/com/example/silverbridgeX_user/batch/job/RecommendationJobConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/job/RecommendationJobConfig.java new file mode 100644 index 0000000..1d252c1 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/job/RecommendationJobConfig.java @@ -0,0 +1,45 @@ +package com.example.silverbridgeX_user.batch.job; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +@EnableBatchProcessing +public class RecommendationJobConfig { + + private final JobRepository jobRepository; + + /* 각 Step Bean 주입 */ + private final Step step1_CTR; + private final Step step2_Rank; + private final Step step3_UserEmb; + private final Step step4_ActEmb; + private final Step step5_Graph; + private final Step step6_Prune; + private final Step step7_FinalRec; + + /** + * 매일 새벽 3시 실행 (Scheduler 에서 호출) + */ + @Bean + public Job recommendationJob() { + return (Job) new JobBuilder("recommendationJob", jobRepository) + .start(step1_CTR) + .next(step2_Rank) + .next(step3_UserEmb) + .next(step4_ActEmb) + .next(step5_Graph) + .next(step6_Prune) + .next(step7_FinalRec) + .build(); + } +} + + diff --git a/src/main/java/com/example/silverbridgeX_user/batch/scheduler/BatchScheduler.java b/src/main/java/com/example/silverbridgeX_user/batch/scheduler/BatchScheduler.java new file mode 100644 index 0000000..026cd20 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/scheduler/BatchScheduler.java @@ -0,0 +1,29 @@ +package com.example.silverbridgeX_user.batch.scheduler; + +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BatchScheduler { + + private final JobLauncher jobLauncher; + private final Job recommendationJob; + + /** + * 매일 새벽 3시(Asia/Seoul) 실행 + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void runRecommendationJob() throws Exception { + JobParameters params = new JobParametersBuilder() + .addLong("run.id", System.currentTimeMillis()) // 파라미터 key는 중복 안 되게 + .toJobParameters(); + + jobLauncher.run(recommendationJob, params); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/CalculateCtrConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/CalculateCtrConfig.java new file mode 100644 index 0000000..fe23947 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/CalculateCtrConfig.java @@ -0,0 +1,21 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.CalculateCtrTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class CalculateCtrConfig { + @Bean + public Step step1_CTR(JobRepository jobRepository, + PlatformTransactionManager txManager, + CalculateCtrTasklet tasklet) { + return new StepBuilder("step1_CTR", jobRepository) + .tasklet(tasklet, txManager) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/GenRecommendationConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/GenRecommendationConfig.java new file mode 100644 index 0000000..84cd54c --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/GenRecommendationConfig.java @@ -0,0 +1,20 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.GenRecommendationTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class GenRecommendationConfig { + @Bean + public Step step7_FinalRec(JobRepository repo, PlatformTransactionManager tx, + GenRecommendationTasklet tasklet) { + return new StepBuilder("step7_FinalRec", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/PruneEdgeConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/PruneEdgeConfig.java new file mode 100644 index 0000000..718f17d --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/PruneEdgeConfig.java @@ -0,0 +1,20 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.PruneEdgeTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class PruneEdgeConfig { + @Bean + public Step step6_Prune(JobRepository repo, PlatformTransactionManager tx, + PruneEdgeTasklet tasklet) { + return new StepBuilder("step6_Prune", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/RequestActEmbConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/RequestActEmbConfig.java new file mode 100644 index 0000000..7477bc1 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/RequestActEmbConfig.java @@ -0,0 +1,21 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.RequestActEmbTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +// STEP 4. +@Configuration +public class RequestActEmbConfig { + @Bean + public Step step4_ActEmb(JobRepository repo, PlatformTransactionManager tx, + RequestActEmbTasklet tasklet) { + return new StepBuilder("step4_ActEmb", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/RequestUserEmbConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/RequestUserEmbConfig.java new file mode 100644 index 0000000..8bcecf3 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/RequestUserEmbConfig.java @@ -0,0 +1,21 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.RequestUserEmbTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +// STEP 3 +@Configuration +public class RequestUserEmbConfig { + @Bean + public Step step3_UserEmb(JobRepository repo, PlatformTransactionManager tx, + RequestUserEmbTasklet tasklet) { + return new StepBuilder("step3_UserEmb", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/SaveTopRankConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/SaveTopRankConfig.java new file mode 100644 index 0000000..7b3c52f --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/SaveTopRankConfig.java @@ -0,0 +1,20 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.SaveTopRankTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class SaveTopRankConfig { + @Bean + public Step step2_Rank(JobRepository repo, PlatformTransactionManager tx, + SaveTopRankTasklet tasklet) { + return new StepBuilder("step2_Rank", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/step/UpdateGraphConfig.java b/src/main/java/com/example/silverbridgeX_user/batch/step/UpdateGraphConfig.java new file mode 100644 index 0000000..00213c5 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/step/UpdateGraphConfig.java @@ -0,0 +1,20 @@ +package com.example.silverbridgeX_user.batch.step; + +import com.example.silverbridgeX_user.batch.tasklet.UpdateGraphTasklet; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +public class UpdateGraphConfig { + @Bean + public Step step5_Graph(JobRepository repo, PlatformTransactionManager tx, + UpdateGraphTasklet tasklet) { + return new StepBuilder("step5_Graph", repo) + .tasklet(tasklet, tx) + .build(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTasklet.java new file mode 100644 index 0000000..07ea4cf --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTasklet.java @@ -0,0 +1,41 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +// STEP 1. CTR 계산, 저장 +@Component +@RequiredArgsConstructor +public class CalculateCtrTasklet implements Tasklet { + + private final JdbcTemplate jdbc; + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) { + String sql = """ + UPDATE activity a SET + click_num = sub.click, + impression_num = sub.impression, + ctr = CASE WHEN sub.impression > 0 + THEN sub.click::float / sub.impression + ELSE 0 END + FROM ( + SELECT activity_id, + COUNT(*) FILTER (WHERE action_type = 'SELECT') AS click, + COUNT(*) FILTER (WHERE action_type = 'VIEW') AS impression + FROM activity_log + GROUP BY activity_id + ) sub + WHERE a.id = sub.activity_id + """; + + jdbc.update(sql); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTasklet.java new file mode 100644 index 0000000..1748885 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTasklet.java @@ -0,0 +1,23 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +// STEP 7. 최종 추천 리스트 생성 및 저장 +@Component +@RequiredArgsConstructor +public class GenRecommendationTasklet implements Tasklet { + private final RecommendActivityService recService; + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) { + recService.generateAllUsersRecommendation(); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTasklet.java new file mode 100644 index 0000000..fa79bdf --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTasklet.java @@ -0,0 +1,28 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +// STEP 6. 불필요한 간선 제거 +@Component +@RequiredArgsConstructor +public class PruneEdgeTasklet implements Tasklet { + private final RecommendActivityService recommendActivityService; + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) { + String del = """ + MATCH (:User)-[r:PREFERRED]->(:Activity) + WHERE r.score < 0.4 + DELETE r + """; + recommendActivityService.execWrite(del); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTasklet.java new file mode 100644 index 0000000..f2a9572 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTasklet.java @@ -0,0 +1,28 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import com.example.silverbridgeX_user.user.repository.UserRepository; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +// STEP 4. 사용자별 SELECT로 연결된 활동 임베딩들의 평균값 계산 -> actEmb 계산 +@Slf4j +@Component +@RequiredArgsConstructor +public class RequestActEmbTasklet implements Tasklet { + + private final UserRepository userRepository; + + @Override + public RepeatStatus execute(@NonNull StepContribution contribution, @NonNull ChunkContext chunkContext) { + + int updatedCount = userRepository.updateActivityEmbeddingAvgForUsers(); + log.info("사용자 활동 임베딩 평균 업데이트 완료: " + updatedCount + " rows affected"); + return RepeatStatus.FINISHED; + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTasklet.java new file mode 100644 index 0000000..e978fad --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTasklet.java @@ -0,0 +1,56 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import com.example.silverbridgeX_user.global.util.EmbeddingClient; +import com.example.silverbridgeX_user.user.domain.User; +import com.example.silverbridgeX_user.user.repository.UserRepository; +import com.example.silverbridgeX_user.user.service.UserService; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +// STEP 3. 사용자별 갱신된 keywords로 prefEmb 구해서 갱신 +@Component +@RequiredArgsConstructor +public class RequestUserEmbTasklet implements Tasklet { + + private final UserRepository userRepo; + private final EmbeddingClient embeddingClient; + private final UserService userService; + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) throws Exception { + + // 사용자별 선호 키워드 업데이트 + userService.updatePreferredKeywords(); + + List users = userRepo.findAll(); + for (User user : users) { + + List keywords = user.getPreferredKeywords(); + if (keywords == null || keywords.isEmpty()) { + continue; + } + + // 키워드들 각각을 임베딩하고, max pooling 값 저장 + String vectorLiteral = embeddingClient.getMaxPooledEmbeddingLiteral(keywords); + + try { + // PostgreSQL vector 타입으로 인식시키기 위해 문자열 그대로 저장 + userRepo.updatePreferredEmbedding(user.getId(), vectorLiteral); + System.out.println("success"); + } catch (Exception e) { + System.err.println("유저 임베딩 실패: " + user.getId()); + e.printStackTrace(); + } + } + + return RepeatStatus.FINISHED; + } + +} + diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTasklet.java new file mode 100644 index 0000000..b21566d --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTasklet.java @@ -0,0 +1,37 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +// STEP 2. CTR 높은 순으로 랭킹 매기기 +@Component +@RequiredArgsConstructor +public class SaveTopRankTasklet implements Tasklet { + + private final JdbcTemplate jdbc; + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) { + jdbc.update("DELETE FROM activity_ranking"); + jdbc.update(""" + INSERT INTO activity_ranking (activity_id, rank) + SELECT id, rank + FROM ( + SELECT id, + RANK() OVER (ORDER BY ctr DESC) AS rank + FROM activity + WHERE ctr > 0 + ) sub + ORDER BY rank + LIMIT 50 + """); + return RepeatStatus.FINISHED; + } +} + diff --git a/src/main/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTasklet.java b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTasklet.java new file mode 100644 index 0000000..310ea34 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTasklet.java @@ -0,0 +1,98 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import java.util.List; +import java.util.Map; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Component; + +// STEP 5. 임베딩 기반 그래프 갱신 +@Component +@RequiredArgsConstructor +public class UpdateGraphTasklet implements Tasklet { + + private final NamedParameterJdbcTemplate jdbc; // PostgreSQL 접근용 + private final RecommendActivityService recommendSvc; // Neo4j 간선 INSERT 담당 + + @Override + public RepeatStatus execute(@NonNull StepContribution con, @NonNull ChunkContext ctx) { + + /*************** 1) 사용자 → 활동 :PREFERRED (Top-2) ****************/ + String preferredSql = """ + WITH cand AS ( + SELECT + u.id AS user_id, + a.id AS activity_id, + 1 - ( 0.3 * cosine_distance(u.preferred_embedding, a.description_embedding) + + 0.7 * cosine_distance(u.activity_embedding_avg, a.description_embedding) ) AS score, + row_number() OVER (PARTITION BY u.id + ORDER BY 1 - ( 0.3 * cosine_distance(u.preferred_embedding, a.description_embedding) + + 0.7 * cosine_distance(u.activity_embedding_avg, a.description_embedding) ) DESC) AS rk + FROM member u + CROSS JOIN activity a + WHERE u.preferred_embedding IS NOT NULL + AND u.activity_embedding_avg IS NOT NULL + AND a.description_embedding IS NOT NULL + -- 거리 < 10 km + AND earth_distance( + ll_to_earth(u.latitude ::float, u.longitude ::float), + ll_to_earth(a.latitude ::float, a.longitude ::float) + ) < 10000 + -- 이미 SELECTED 인 활동 제외 + AND NOT EXISTS ( + SELECT 1 FROM activity_log al + WHERE al.user_id = u.id + AND al.activity_id = a.id + AND al.action_type = 'SELECT' + ) + ) + SELECT user_id, activity_id, score + FROM cand + WHERE score >= 0.5 + AND rk <= 2; + """; + + List> prefRows = jdbc.queryForList(preferredSql, Map.of()); + recommendSvc.insertPreferredEdges(prefRows); // Neo4j MERGE (u)-[:PREFERRED]->(a) + + /*************** 2) 사용자 ↔ 사용자 :SIMILAR ****************/ + String similarSql = """ + WITH pair AS ( + SELECT + u1.id AS uid1, + u2.id AS uid2, + 1 - cosine_distance(u1.preferred_embedding, u2.preferred_embedding ) AS pref_sim, + 1 - cosine_distance(u1.activity_embedding_avg, u2.activity_embedding_avg ) AS act_sim, + (SELECT COUNT(*) FROM activity_log al WHERE al.user_id = u1.id AND al.action_type = 'SELECT') AS cnt1, + (SELECT COUNT(*) FROM activity_log al WHERE al.user_id = u2.id AND al.action_type = 'SELECT') AS cnt2 + FROM member u1 + JOIN member u2 ON u1.id < u2.id + WHERE u1.preferred_embedding IS NOT NULL + AND u2.preferred_embedding IS NOT NULL + ) + SELECT + uid1, uid2, + CASE + WHEN cnt1 = 0 AND cnt2 > 0 THEN pref_sim -- cold-start + ELSE 0.7 * pref_sim + 0.3 * act_sim + END AS w_sim + FROM pair + WHERE + (CASE + WHEN cnt1 = 0 AND cnt2 > 0 THEN pref_sim + ELSE 0.7 * pref_sim + 0.3 * act_sim + END) >= 0.5; + """; + + List> simRows = jdbc.queryForList(similarSql, Map.of()); + recommendSvc.insertSimilarEdges(simRows); // Neo4j MERGE (u1)-[:SIMILAR]-(u2) + + return RepeatStatus.FINISHED; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/global/api_payload/ErrorCode.java b/src/main/java/com/example/silverbridgeX_user/global/api_payload/ErrorCode.java index 4a20715..d97b30b 100644 --- a/src/main/java/com/example/silverbridgeX_user/global/api_payload/ErrorCode.java +++ b/src/main/java/com/example/silverbridgeX_user/global/api_payload/ErrorCode.java @@ -31,6 +31,9 @@ public enum ErrorCode implements BaseCode { // Activity ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "ACTIVITY_4041", "존재하지 않는 활동입니다."), + // Recommend Activity + RECOMMEND_ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "RECOMMEND_4041", "존재하지 않는 추천입니다."), + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java b/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java index 80266c1..c580150 100644 --- a/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java +++ b/src/main/java/com/example/silverbridgeX_user/global/api_payload/SuccessCode.java @@ -23,6 +23,11 @@ public enum SuccessCode implements BaseCode { USER_PROFILE_IMAGE_UPDATE_SUCCESS(HttpStatus.OK, "USER_2007", "프로필 사진 이미지 업로드가 완료 되었습니다."), USER_MYPAGE_VIEW_SUCCESS(HttpStatus.OK, "USER_2010", "마이페이지 정보 조회가 완료되었습니다."), + // Recommend Activity + RECOMMEND_ACTIVITY_VIEW_LIST_SUCCESS(HttpStatus.OK, "ACTIVITY_2001", "추천하는 활동 리스를 반환 완료했습니다."), + RECOMMEND_ACTIVITY_SELECT_LOG_SUCCESS(HttpStatus.OK, "ACTIVITY_2002", "선택한 활동 로그와 selected 간선 저장 완료했습니다."), + RECOMMEND_ACTIVITY_VIEW_LOG_SUCCESS(HttpStatus.OK, "ACTIVITY_2002", "열람한 활동 로그를 저장 완료했습니다."), + // MatchRequest MATCH_REQUEST_SUCCESS(HttpStatus.OK, "MATCH_REQUEST_2001", "매치 신청이 완료되었습니다."), ; diff --git a/src/main/java/com/example/silverbridgeX_user/global/config/RestTemplateConfig.java b/src/main/java/com/example/silverbridgeX_user/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..6bf3e19 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/global/config/RestTemplateConfig.java @@ -0,0 +1,13 @@ +package com.example.silverbridgeX_user.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/global/config/SchedulerConfig.java b/src/main/java/com/example/silverbridgeX_user/global/config/SchedulerConfig.java new file mode 100644 index 0000000..84d3e96 --- /dev/null +++ b/src/main/java/com/example/silverbridgeX_user/global/config/SchedulerConfig.java @@ -0,0 +1,15 @@ +package com.example.silverbridgeX_user.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + return new ThreadPoolTaskScheduler(); + } +} diff --git a/src/main/java/com/example/silverbridgeX_user/global/util/EmbeddingClient.java b/src/main/java/com/example/silverbridgeX_user/global/util/EmbeddingClient.java index d03192e..85658b6 100644 --- a/src/main/java/com/example/silverbridgeX_user/global/util/EmbeddingClient.java +++ b/src/main/java/com/example/silverbridgeX_user/global/util/EmbeddingClient.java @@ -5,16 +5,20 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.util.List; import java.util.Scanner; import java.util.stream.Collectors; import java.util.stream.DoubleStream; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class EmbeddingClient { + @Value("${ai.server.url}") + private String aiServerUrl; public String getEmbeddingLiteral(String text) throws Exception { - URL url = new URL("http://localhost:8001/embed"); + URL url = new URL(aiServerUrl + "/embedding"); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); @@ -37,4 +41,40 @@ public String getEmbeddingLiteral(String text) throws Exception { .mapToObj(Double::toString) .collect(Collectors.joining(", ")) + "]"; } + + public String getMaxPooledEmbeddingLiteral(List keywords) throws Exception { + if (keywords == null || keywords.isEmpty()) { + return null; + } + + // 각 키워드 임베딩 추출 + double[][] embeddings = new double[keywords.size()][]; + + for (int i = 0; i < keywords.size(); i++) { + String keyword = keywords.get(i); + String vectorStr = getEmbeddingLiteral(keyword); // "[0.1, 0.2, ...]" + String[] parts = vectorStr.replace("[", "").replace("]", "").split(","); + embeddings[i] = new double[parts.length]; + for (int j = 0; j < parts.length; j++) { + embeddings[i][j] = Double.parseDouble(parts[j].trim()); + } + } + + // max pooling 계산 + int dim = embeddings[0].length; + double[] maxPooled = new double[dim]; + for (int d = 0; d < dim; d++) { + double max = Double.NEGATIVE_INFINITY; + for (double[] vec : embeddings) { + max = Math.max(max, vec[d]); + } + maxPooled[d] = max; + } + + // PostgreSQL vector 타입용 문자열 생성 + return "[" + DoubleStream.of(maxPooled) + .mapToObj(d -> String.format("%.6f", d)) + .collect(Collectors.joining(", ")) + "]"; + } + } \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/matching/domain/MatchStatus.java b/src/main/java/com/example/silverbridgeX_user/matching/domain/MatchStatus.java index 3a77682..bbf7d7d 100644 --- a/src/main/java/com/example/silverbridgeX_user/matching/domain/MatchStatus.java +++ b/src/main/java/com/example/silverbridgeX_user/matching/domain/MatchStatus.java @@ -3,4 +3,4 @@ public enum MatchStatus { WAITING, MATCHED -} +} \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/user/controller/TokenController.java b/src/main/java/com/example/silverbridgeX_user/user/controller/TokenController.java index 5460bb7..4247f16 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/controller/TokenController.java +++ b/src/main/java/com/example/silverbridgeX_user/user/controller/TokenController.java @@ -41,16 +41,16 @@ public ApiResponse tokenToFront( String signIn = "wasUser"; if (isMember) { - User user = userService.findByUserName(userReqDto.getUsername()); + User user = userService.findByUserName(userReqDto.getEmail()); - JwtDto jwt = userService.jwtMakeSave(userReqDto.getUsername()); + JwtDto jwt = userService.jwtMakeSave(userReqDto.getEmail()); accessToken = jwt.getAccessToken(); refreshToken = jwt.getRefreshToken(); } else { User newUser = userService.createUser(userReqDto); - JwtDto jwt = userService.jwtMakeSave(userReqDto.getUsername()); + JwtDto jwt = userService.jwtMakeSave(userReqDto.getEmail()); accessToken = jwt.getAccessToken(); refreshToken = jwt.getRefreshToken(); diff --git a/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java b/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java index 5940dd6..9a050b9 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java +++ b/src/main/java/com/example/silverbridgeX_user/user/converter/UserConverter.java @@ -3,15 +3,13 @@ import com.example.silverbridgeX_user.user.domain.User; import com.example.silverbridgeX_user.user.dto.JwtDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto; -import lombok.NoArgsConstructor; -@NoArgsConstructor public class UserConverter { public static User saveUser(UserRequestDto.UserReqDto userReqDto) { return User.builder() .email(userReqDto.getEmail()) - .username(userReqDto.getUsername()) + .username(userReqDto.getEmail()) .nickname(userReqDto.getNickname()) .build(); } diff --git a/src/main/java/com/example/silverbridgeX_user/user/domain/Persona.java b/src/main/java/com/example/silverbridgeX_user/user/domain/Persona.java deleted file mode 100644 index fdb77da..0000000 --- a/src/main/java/com/example/silverbridgeX_user/user/domain/Persona.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.silverbridgeX_user.user.domain; - -import jakarta.persistence.*; -import lombok.*; -import java.util.List; -import org.hibernate.type.SqlTypes; -import org.hibernate.annotations.JdbcTypeCode; - -@Entity -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "persona") -public class Persona { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @OneToOne - private User user; - - private String tone; - - @JdbcTypeCode(SqlTypes.JSON) - private List preferredKeywords; - - @Column(name = "preferred_embedding", columnDefinition = "vector(384)") - private String preferredEmbedding; - - @JdbcTypeCode(SqlTypes.JSON) - private List dislikeKeywords; - -} diff --git a/src/main/java/com/example/silverbridgeX_user/user/domain/User.java b/src/main/java/com/example/silverbridgeX_user/user/domain/User.java index f3a0fe0..fec31a4 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/domain/User.java +++ b/src/main/java/com/example/silverbridgeX_user/user/domain/User.java @@ -2,14 +2,22 @@ import com.example.silverbridgeX_user.global.entity.BaseEntity; import com.example.silverbridgeX_user.matching.domain.MatchRequest; -import jakarta.persistence.*; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - -import java.util.regex.MatchResult; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Getter @@ -46,6 +54,21 @@ public class User extends BaseEntity { @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) private MatchRequest matchRequest; + @JdbcTypeCode(SqlTypes.JSON) + private List preferredKeywords; + + @JdbcTypeCode(SqlTypes.JSON) + private List dislikeKeywords; + + @Column(name = "activity_embedding_avg", columnDefinition = "vector(384)", insertable = false, updatable = false) + private String activityEmbeddingAvg; + + @Column(name = "preferred_embedding", columnDefinition = "vector(384)", insertable = false, updatable = false) + private String preferredEmbedding; + + @Column(name = "dislike_embedding", columnDefinition = "vector(384)", insertable = false, updatable = false) + private String dislikeEmbedding; + public User(String username, String nickname, String email) { this.username = username; this.nickname = nickname; @@ -56,6 +79,9 @@ public void updateNickname(String nickname) { this.nickname = nickname; } + public void updatePreferredKeywords(List keywords) { + this.preferredKeywords = keywords; + } } diff --git a/src/main/java/com/example/silverbridgeX_user/user/dto/UserRequestDto.java b/src/main/java/com/example/silverbridgeX_user/user/dto/UserRequestDto.java index 79c9993..f5224a4 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/dto/UserRequestDto.java +++ b/src/main/java/com/example/silverbridgeX_user/user/dto/UserRequestDto.java @@ -1,6 +1,7 @@ package com.example.silverbridgeX_user.user.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,9 +20,6 @@ public static class UserReqDto { @Schema(description = "이메일") private String email; - @Schema(description = "이메일id{social}") - private String username; - @Schema(description = "닉네임") private String nickname; @@ -58,4 +56,16 @@ public static class UserAddressReqDto { @Schema(description = "상세 주소") private String detailedAddress; } + + @Schema(description = "UserPreferenceDto") + @Getter + @Setter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class UserPreferenceDto { + private Long userId; + private List preferences; + } + } diff --git a/src/main/java/com/example/silverbridgeX_user/user/repository/PersonaRepository.java b/src/main/java/com/example/silverbridgeX_user/user/repository/PersonaRepository.java deleted file mode 100644 index d6f3e01..0000000 --- a/src/main/java/com/example/silverbridgeX_user/user/repository/PersonaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.example.silverbridgeX_user.user.repository; - -import com.example.silverbridgeX_user.user.domain.Persona; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PersonaRepository extends JpaRepository { -} diff --git a/src/main/java/com/example/silverbridgeX_user/user/repository/UserRepository.java b/src/main/java/com/example/silverbridgeX_user/user/repository/UserRepository.java index 0f7e5d7..aec249c 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/repository/UserRepository.java +++ b/src/main/java/com/example/silverbridgeX_user/user/repository/UserRepository.java @@ -3,18 +3,48 @@ import com.example.silverbridgeX_user.user.domain.User; import java.util.Optional; 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; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository public interface UserRepository extends JpaRepository { // 1. 사용자 계정이름으로 사용자 정보를 회수하는 기능 Optional findByUsername(String username); + // 2. 사용자 이메일으로 사용자 정보를 회수하는 기능 Optional findByEmail(String email); + // 3. 사용자 계정이름을 가진 사용자 정보가 존재하는지 판단하는 기능 boolean existsByUsername(String username); + // 4. 사용자 이메일을 가진 사용자 정보가 존재하는지 판단하는 기능 boolean existsByEmail(String email); + // 5. 닉네임이 사용중인지 판단하는 기능 boolean existsByNickname(String nickname); + + @Modifying + @Transactional + @Query(value = "UPDATE member SET preferred_embedding = CAST(:embedding AS vector) WHERE id = :userId", nativeQuery = true) + void updatePreferredEmbedding(@Param("userId") Long userId, @Param("embedding") String embedding); + + @Modifying(clearAutomatically = true) + @Transactional + @Query(value = """ + UPDATE member u + SET activity_embedding_avg = CAST(sub.avg_vec AS vector) + FROM ( + SELECT al.user_id, + avg(a.description_embedding) AS avg_vec + FROM activity_log al + JOIN activity a ON al.activity_id = a.id + WHERE al.action_type = 'SELECT' AND a.description_embedding IS NOT NULL + GROUP BY al.user_id + ) sub + WHERE u.id = sub.user_id + """, nativeQuery = true) + int updateActivityEmbeddingAvgForUsers(); } \ No newline at end of file diff --git a/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java b/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java index 3d87862..ada5a3c 100644 --- a/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java +++ b/src/main/java/com/example/silverbridgeX_user/user/service/UserService.java @@ -7,9 +7,11 @@ import com.example.silverbridgeX_user.user.domain.User; import com.example.silverbridgeX_user.user.dto.JwtDto; import com.example.silverbridgeX_user.user.dto.UserRequestDto; +import com.example.silverbridgeX_user.user.dto.UserRequestDto.UserPreferenceDto; import com.example.silverbridgeX_user.user.jwt.JwtTokenUtils; import com.example.silverbridgeX_user.user.repository.RefreshTokenRepository; import com.example.silverbridgeX_user.user.repository.UserRepository; +import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import java.util.List; @@ -19,8 +21,11 @@ import lombok.extern.slf4j.Slf4j; import org.neo4j.driver.Driver; import org.neo4j.driver.Session; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; @Slf4j @Service @@ -30,9 +35,18 @@ public class UserService { private final RefreshTokenRepository refreshTokenRepository; private final JpaUserDetailsManager manager; private final JwtTokenUtils jwtTokenUtils; - + private final RestTemplate restTemplate; private final Driver driver; + @Value("${chat.server.url}") + private String chatServerUrl; + private String PREF_URL; + + @PostConstruct + public void init() { + this.PREF_URL = chatServerUrl + "/summary/preferences/all"; + } + @Transactional public User findByUserName(String userName) { return userRepository.findByUsername(userName) @@ -51,9 +65,9 @@ public User createUser(UserRequestDto.UserReqDto userReqDto) { userRepository.save(newUser); // Neo4j 사용자 노드 만들기 - //insertUserNodeIfNotExists(newUser.getId(), newUser.getUsername()); + insertUserNodeIfNotExists(newUser.getId()); - manager.loadUserByUsername(userReqDto.getUsername()); // 저장된 사용자 정보를 다시 로드하여 동기화 시도 + manager.loadUserByUsername(userReqDto.getEmail()); // 저장된 사용자 정보를 다시 로드하여 동기화 시도 return newUser; } @@ -138,7 +152,7 @@ public void deleteUser(String username) { log.info("{} 회원 탈퇴 완료", username); } - public void insertUserNodeIfNotExists(Long userId, String name) { + public void insertUserNodeIfNotExists(Long userId) { try (Session session = driver.session()) { session.writeTransaction(tx -> { // 존재 여부 확인 @@ -151,20 +165,14 @@ public void insertUserNodeIfNotExists(Long userId, String name) { if (!exists) { tx.run(""" CREATE (u:User { - id: $id, - name: $name, - keywords: $keywords, - embedding: $embedding + id: $id }) """, Map.of( - "id", userId, - "name", name, - "keywords", List.of(), // 기본 null 대신 빈 리스트 - "embedding", List.of() // 빈 float 배열 + "id", userId )); - System.out.println("사용자 노드 생성 완료: " + name); + log.info("사용자 노드 생성 완료: " + userId); } else { - System.out.println("이미 존재: " + name); + log.info("이미 존재: " + userId); } return null; }); @@ -181,4 +189,25 @@ public void saveNickname(UserRequestDto.UserNicknameReqDto nicknameReqDto, User user.updateNickname(nickname); } + public void updatePreferredKeywords() { + ResponseEntity response + = restTemplate.getForEntity(PREF_URL, UserPreferenceDto[].class); + UserPreferenceDto[] dtos = response.getBody(); + + if (dtos == null) { + return; + } + + for (UserPreferenceDto dto : dtos) { + userRepository.findById(dto.getUserId()).ifPresent(user -> { + List deduplicated = dto.getPreferences().stream() + .distinct() + .toList(); + + user.updatePreferredKeywords(deduplicated); + userRepository.save(user); + log.info(user.toString()); + }); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0dbaf1b..f0f31d5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,11 @@ neo4j: username: ${neo4j.authentication.username} password: ${neo4j.authentication.password} +logging: + level: + org.springframework.batch: DEBUG + org.springframework.jdbc.datasource.init: DEBUG + --- # local profile @@ -39,6 +44,9 @@ spring: username: ${local.db.username} password: ${local.db.password} driver-class-name: org.postgresql.Driver + batch: + jdbc: + initialize-schema: always sql: init: mode: never diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTaskletTest.java new file mode 100644 index 0000000..f4d1718 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/CalculateCtrTaskletTest.java @@ -0,0 +1,29 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.jdbc.core.JdbcTemplate; + +public class CalculateCtrTaskletTest { + @Test + void execute_updatesCtrSuccessfully() throws Exception { + // Given + JdbcTemplate mockJdbc = mock(JdbcTemplate.class); + CalculateCtrTasklet tasklet = new CalculateCtrTasklet(mockJdbc); + StepContribution mockCon = mock(StepContribution.class); + ChunkContext mockCtx = mock(ChunkContext.class); + + // When + tasklet.execute(mockCon, mockCtx); + + // Then + verify(mockJdbc, times(1)).update(anyString()); + } + +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTaskletTest.java new file mode 100644 index 0000000..08367fb --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/GenRecommendationTaskletTest.java @@ -0,0 +1,25 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; + +public class GenRecommendationTaskletTest { + @Test + void execute_generatesRecommendation() throws Exception { + // Given + RecommendActivityService mockService = mock(RecommendActivityService.class); + GenRecommendationTasklet tasklet = new GenRecommendationTasklet(mockService); + + // When + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // Then + verify(mockService, times(1)).generateAllUsersRecommendation(); + } +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTaskletTest.java new file mode 100644 index 0000000..3508b90 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/PruneEdgeTaskletTest.java @@ -0,0 +1,25 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; + +public class PruneEdgeTaskletTest { + @Test + void execute_deletesLowScoreEdges() throws Exception { + // Given + RecommendActivityService mockService = mock(RecommendActivityService.class); + PruneEdgeTasklet tasklet = new PruneEdgeTasklet(mockService); + + // When + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // Then + verify(mockService).execWrite(anyString()); + } +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTaskletTest.java new file mode 100644 index 0000000..290b6ca --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestActEmbTaskletTest.java @@ -0,0 +1,29 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.silverbridgeX_user.user.repository.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; + +public class RequestActEmbTaskletTest { + @Test + void execute_updatesEmbeddingAvg() throws Exception { + // Given + UserRepository mockRepo = mock(UserRepository.class); + when(mockRepo.updateActivityEmbeddingAvgForUsers()).thenReturn(3); + + RequestActEmbTasklet tasklet = new RequestActEmbTasklet(mockRepo); + + // When + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + // Then + verify(mockRepo, times(1)).updateActivityEmbeddingAvgForUsers(); + } + +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTest.java new file mode 100644 index 0000000..f90d6a3 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTest.java @@ -0,0 +1,108 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.anyList; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.silverbridgeX_user.global.util.EmbeddingClient; +import com.example.silverbridgeX_user.user.domain.User; +import com.example.silverbridgeX_user.user.repository.UserRepository; +import com.example.silverbridgeX_user.user.service.UserService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.repeat.RepeatStatus; + +public class RequestUserEmbTaskletTest { + + @InjectMocks + private RequestUserEmbTasklet tasklet; + + @Mock + private UserRepository userRepository; + + @Mock + private EmbeddingClient embeddingClient; + + @Mock + private UserService userService; + + @Mock + private StepContribution contribution; + + @Mock + private ChunkContext chunkContext; + + @Mock + private User user; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void execute_successfully_updates_embeddings() throws Exception { + // Given + when(user.getId()).thenReturn(1L); + when(user.getPreferredKeywords()).thenReturn(List.of("문화", "자연")); + + when(userRepository.findAll()).thenReturn(List.of(user)); + when(embeddingClient.getMaxPooledEmbeddingLiteral(anyList())) + .thenReturn("'[0.1, 0.2, 0.3]'"); + + // When + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // Then + verify(userService).updatePreferredKeywords(); + verify(userRepository).updatePreferredEmbedding(eq(1L), anyString()); + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + } + + @Test + void execute_skips_user_with_no_keywords() throws Exception { + // Given + when(user.getPreferredKeywords()).thenReturn(List.of()); + + when(userRepository.findAll()).thenReturn(List.of(user)); + + // When + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // Then + verify(userRepository, never()).updatePreferredEmbedding(anyLong(), anyString()); + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + } + + @Test + void execute_handles_exception_during_embedding() throws Exception { + // Given + when(user.getId()).thenReturn(99L); + when(user.getPreferredKeywords()).thenReturn(List.of("키워드")); + + when(userRepository.findAll()).thenReturn(List.of(user)); + when(embeddingClient.getMaxPooledEmbeddingLiteral(anyList())) + .thenReturn("'[0.1, 0.2]'"); + doThrow(new RuntimeException("DB Error")) + .when(userRepository).updatePreferredEmbedding(anyLong(), anyString()); + + // When + RepeatStatus status = tasklet.execute(contribution, chunkContext); + + // Then + verify(userRepository).updatePreferredEmbedding(eq(99L), anyString()); + assertThat(status).isEqualTo(RepeatStatus.FINISHED); + } +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTestKt.kt b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTestKt.kt new file mode 100644 index 0000000..c7233d4 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/RequestUserEmbTaskletTestKt.kt @@ -0,0 +1,89 @@ +package com.example.silverbridgeX_user.batch.tasklet + +import com.example.silverbridgeX_user.global.util.EmbeddingClient +import com.example.silverbridgeX_user.user.domain.User +import com.example.silverbridgeX_user.user.repository.UserRepository +import com.example.silverbridgeX_user.user.service.UserService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.* +import org.springframework.batch.core.StepContribution +import org.springframework.batch.core.scope.context.ChunkContext +import org.springframework.batch.repeat.RepeatStatus + +class RequestUserEmbTaskletTestKt { + + private val userRepository: UserRepository = mock() + private val embeddingClient: EmbeddingClient = mock() + private val userService: UserService = mock() + private val contribution: StepContribution = mock() + private val chunkContext: ChunkContext = mock() + + private lateinit var tasklet: RequestUserEmbTasklet + + @BeforeEach + fun setUp() { + tasklet = RequestUserEmbTasklet(userRepository, embeddingClient, userService) + } + + @Test + fun `사용자의 키워드가 존재하면 임베딩이 갱신된다`() { + // Given + val user = mock { + on { id } doReturn 1L + on { preferredKeywords } doReturn listOf("문화", "자연") + } + + whenever(userRepository.findAll()).thenReturn(listOf(user)) + whenever(embeddingClient.getMaxPooledEmbeddingLiteral(any())) + .thenReturn("'[0.1, 0.2, 0.3]'") + + // When + val result = tasklet.execute(contribution, chunkContext) + + // Then + verify(userService).updatePreferredKeywords() + verify(userRepository).updatePreferredEmbedding(eq(1L), any()) + assertThat(result).isEqualTo(RepeatStatus.FINISHED) + } + + @Test + fun `사용자의 키워드가 없으면 임베딩 갱신을 건너뛴다`() { + // Given + val user = mock { + on { preferredKeywords } doReturn emptyList() + } + + whenever(userRepository.findAll()).thenReturn(listOf(user)) + + // When + val result = tasklet.execute(contribution, chunkContext) + + // Then + verify(userRepository, never()).updatePreferredEmbedding(any(), any()) + assertThat(result).isEqualTo(RepeatStatus.FINISHED) + } + + @Test + fun `임베딩 갱신 중 예외가 발생해도 정상 종료된다`() { + // Given + val user = mock { + on { id } doReturn 99L + on { preferredKeywords } doReturn listOf("키워드") + } + + whenever(userRepository.findAll()).thenReturn(listOf(user)) + whenever(embeddingClient.getMaxPooledEmbeddingLiteral(any())) + .thenReturn("'[0.1, 0.2]'") + doThrow(RuntimeException("DB Error")) + .whenever(userRepository).updatePreferredEmbedding(eq(99L), any()) + + // When + val result = tasklet.execute(contribution, chunkContext) + + // Then + verify(userRepository).updatePreferredEmbedding(eq(99L), any()) + assertThat(result).isEqualTo(RepeatStatus.FINISHED) + } +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTaskletTest.java new file mode 100644 index 0000000..45b5ca6 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/SaveTopRankTaskletTest.java @@ -0,0 +1,30 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.jdbc.core.JdbcTemplate; + +public class SaveTopRankTaskletTest { + @Test + void execute_ranksAndInsertsSuccessfully() throws Exception { + // Given + JdbcTemplate mockJdbc = mock(JdbcTemplate.class); + SaveTopRankTasklet tasklet = new SaveTopRankTasklet(mockJdbc); + + StepContribution mockCon = mock(StepContribution.class); + ChunkContext mockCtx = mock(ChunkContext.class); + + // When + tasklet.execute(mockCon, mockCtx); + + // Then + verify(mockJdbc, times(1)).update("DELETE FROM activity_ranking"); + verify(mockJdbc, times(2)).update(anyString()); + } +} diff --git a/src/test/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTaskletTest.java b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTaskletTest.java new file mode 100644 index 0000000..32abf99 --- /dev/null +++ b/src/test/java/com/example/silverbridgeX_user/batch/tasklet/UpdateGraphTaskletTest.java @@ -0,0 +1,32 @@ +package com.example.silverbridgeX_user.batch.tasklet; + +import static org.mockito.Mockito.anyList; +import static org.mockito.Mockito.anyMap; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.silverbridgeX_user.RecommendActivity.service.RecommendActivityService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; + +public class UpdateGraphTaskletTest { + @Test + void execute_updatesGraphEdges() throws Exception { + NamedParameterJdbcTemplate jdbc = mock(NamedParameterJdbcTemplate.class); + RecommendActivityService svc = mock(RecommendActivityService.class); + + when(jdbc.queryForList(anyString(), anyMap())).thenReturn(List.of(Map.of("dummy", "value"))); + + UpdateGraphTasklet tasklet = new UpdateGraphTasklet(jdbc, svc); + tasklet.execute(mock(StepContribution.class), mock(ChunkContext.class)); + + verify(svc).insertPreferredEdges(anyList()); + verify(svc).insertSimilarEdges(anyList()); + } +}