diff --git a/.gitignore b/.gitignore index f930e11..e30e720 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ out/ ### Setting ### .env +/secrets diff --git a/build.gradle b/build.gradle index d068786..1080935 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,7 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-openai' implementation 'org.springframework.ai:spring-ai-starter-vector-store-pgvector' + implementation 'com.google.auth:google-auth-library-oauth2-http:1.23.0' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-redis' @@ -68,6 +69,10 @@ tasks.named('test') { useJUnitPlatform() } +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + tasks.named('asciidoctor') { inputs.dir snippetsDir dependsOn test diff --git a/src/main/java/com/jjikmeok/app/AppApplication.java b/src/main/java/com/jjikmeok/app/AppApplication.java index 6b36cc7..d7d090c 100644 --- a/src/main/java/com/jjikmeok/app/AppApplication.java +++ b/src/main/java/com/jjikmeok/app/AppApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing +@EnableScheduling @SpringBootApplication @ConfigurationPropertiesScan public class AppApplication { diff --git a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityController.java b/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityController.java index b56cb9a..d386ee1 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityController.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityController.java @@ -130,7 +130,7 @@ public ApiResponse getActivity( @PreAuthorize("hasRole('ADMIN')") public ApiResponse createActivity( @RequestBody @Valid ActivityRequest request) { - return ApiResponse.success("활동 생성 성공", activityService.createActivity(request)); + return ApiResponse.created("활동 생성 성공", activityService.createActivity(request)); } @Operation(summary = "활동 수정 (관리자)", description = "특정 활동 정보를 수정합니다.") diff --git a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityFavoriteController.java b/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityFavoriteController.java deleted file mode 100644 index 7967775..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityFavoriteController.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.jjikmeok.app.domain.activity.controller; - -import com.jjikmeok.app.domain.activity.dto.request.ActivityFavoriteRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityFavoriteResponse; -import com.jjikmeok.app.domain.activity.service.ActivityFavoriteService; -import com.jjikmeok.app.global.common.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -@Tag(name = "Activity Favorite", description = "활동 찜 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/activities/favorites") -public class ActivityFavoriteController { - - private final ActivityFavoriteService favoriteService; - - @Operation(summary = "내 활동 찜 목록 조회") - @GetMapping - public ApiResponse> getFavorites(@AuthenticationPrincipal Long userId) { - return ApiResponse.success("활동 찜 목록 조회 성공", favoriteService.getFavorites(userId)); - } - - @Operation(summary = "활동 찜 생성") - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - public ApiResponse createFavorite( - @AuthenticationPrincipal Long userId, - @RequestBody @Valid ActivityFavoriteRequest request) { - return ApiResponse.success("활동 찜 생성 성공", favoriteService.createFavorite(userId, request)); - } - - @Operation(summary = "활동 찜 삭제") - @DeleteMapping("/{activityId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void deleteFavorite( - @AuthenticationPrincipal Long userId, - @PathVariable("activityId") Long activityId) { - favoriteService.deleteFavorite(userId, activityId); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/controller/AdminActivityIngestionController.java b/src/main/java/com/jjikmeok/app/domain/activity/controller/AdminActivityIngestionController.java new file mode 100644 index 0000000..99031b3 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/controller/AdminActivityIngestionController.java @@ -0,0 +1,68 @@ +package com.jjikmeok.app.domain.activity.controller; + +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.service.AdminActivityIngestionService; +import com.jjikmeok.app.domain.activity.privateactivity.dto.response.DiscoverySheetRowDto; +import com.jjikmeok.app.domain.activity.publicactivity.dto.ActivitySyncResponse; +import com.jjikmeok.app.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +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; + +import java.util.List; + +@Tag(name = "활동 수집 관리자 API", description = "공공/민간/디스커버리 활동 수집 관리자 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/activities/sources") +@PreAuthorize("hasRole('ADMIN')") +public class AdminActivityIngestionController { + + private final AdminActivityIngestionService adminActivityIngestionService; + + @Operation(summary = "공공 활동 소스 전체 동기화") + @PostMapping("/public/sync") + public ApiResponse syncAllPublicSources() { + adminActivityIngestionService.syncAllPublicSources(); + return ApiResponse.success("공공 활동 동기화를 시작했습니다.", "상세 내용은 서버 로그를 확인하세요."); + } + + @Operation(summary = "공공 활동 소스 개별 동기화") + @PostMapping("/public/{sourceType}/sync") + public ApiResponse syncPublicSource( + @PathVariable("sourceType") SourceType sourceType, + @RequestParam(required = false) Integer maxPages + ) { + return ApiResponse.success( + "공공 활동 소스 동기화를 완료했습니다.", + adminActivityIngestionService.syncPublicSource(sourceType, maxPages) + ); + } + + @Operation(summary = "디스커버리 활동 후보 수집") + @PostMapping("/discovery/collect") + public ApiResponse> collectDiscoveryActivities( + @RequestParam(required = false) Integer keywordLimit, + @RequestParam(required = false) Integer resultLimit + ) { + return ApiResponse.success( + "디스커버리 활동 후보 수집을 완료했습니다.", + adminActivityIngestionService.collectDiscoveryActivities(keywordLimit, resultLimit) + ); + } + + @Operation(summary = "발행 대기 디스커버리 활동 발행") + @PostMapping("/discovery/publish") + public ApiResponse publishDiscoveryActivities() { + return ApiResponse.success( + "디스커버리 활동 발행을 완료했습니다.", + adminActivityIngestionService.publishDiscoveryActivities() + ); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityConverter.java b/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityConverter.java index 771f9ea..a9e87f1 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityConverter.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityConverter.java @@ -19,6 +19,9 @@ public static Activity toEntity(ActivityRequest request, Region region) { .thumbnailUrl(request.thumbnailUrl()) .sourceUrl(request.sourceUrl()) .address(request.address()) + .organizer(request.organizer()) + .contactInfo(request.contactInfo()) + .target(request.target()) .startAt(request.startAt()) .endAt(request.endAt()) .recruitStartAt(request.recruitStartAt()) diff --git a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityFavoriteConverter.java b/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityFavoriteConverter.java deleted file mode 100644 index e73140b..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityFavoriteConverter.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.jjikmeok.app.domain.activity.converter; - -import com.jjikmeok.app.domain.activity.dto.response.ActivityFavoriteResponse; -import com.jjikmeok.app.domain.activity.entity.ActivityFavorite; - -public class ActivityFavoriteConverter { - - private ActivityFavoriteConverter() { - } - - public static ActivityFavoriteResponse toResponse(ActivityFavorite favorite) { - return new ActivityFavoriteResponse( - favorite.getId(), - favorite.getUser().getId(), - favorite.getActivity().getId(), - favorite.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityReviewConverter.java b/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityReviewConverter.java deleted file mode 100644 index f6ec2a7..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityReviewConverter.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.jjikmeok.app.domain.activity.converter; - -import com.jjikmeok.app.domain.activity.dto.response.ActivityReviewResponse; -import com.jjikmeok.app.domain.activity.entity.ActivityReview; - -public class ActivityReviewConverter { - - private ActivityReviewConverter() { - } - - public static ActivityReviewResponse toResponse(ActivityReview review) { - return new ActivityReviewResponse( - review.getId(), - review.getUser().getId(), - review.getActivity().getId(), - review.getRating(), - review.getReason(), - review.getLikeCount(), - review.getCreatedAt(), - review.getUpdatedAt() - ); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityRequest.java b/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityRequest.java index d9114ac..9fe5a15 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityRequest.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityRequest.java @@ -17,6 +17,9 @@ public record ActivityRequest( String thumbnailUrl, @NotBlank(message = "sourceUrl은 필수입니다.") String sourceUrl, String address, + String organizer, + String contactInfo, + String target, LocalDateTime startAt, LocalDateTime endAt, LocalDateTime recruitStartAt, @@ -28,4 +31,47 @@ public record ActivityRequest( String externalId, ApprovalStatus approvalStatus, Boolean isActive -) {} +) { + public ActivityRequest( + Long regionId, + String title, + String description, + String thumbnailUrl, + String sourceUrl, + String address, + LocalDateTime startAt, + LocalDateTime endAt, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + Integer price, + ActivityType activityType, + ActivityCategory category, + SourceType sourceType, + String externalId, + ApprovalStatus approvalStatus, + Boolean isActive + ) { + this( + regionId, + title, + description, + thumbnailUrl, + sourceUrl, + address, + null, + null, + null, + startAt, + endAt, + recruitStartAt, + recruitEndAt, + price, + activityType, + category, + sourceType, + externalId, + approvalStatus, + isActive + ); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCategoryPageResponse.java b/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCategoryPageResponse.java deleted file mode 100644 index 7f2dc4f..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCategoryPageResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.jjikmeok.app.domain.activity.dto.response.page; - -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.page.dto.response.ActivityCardResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityFilterOptionResponse; - -import java.util.List; - -public record ActivityCategoryPageResponse( - String pageTitle, - ActivityType selectedType, - ActivityCategory selectedCategory, - String selectedSort, - Long totalCount, - List typeTabs, - List categoryChips, - List sortOptions, - List activities -) { -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCustomPageResponse.java b/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCustomPageResponse.java deleted file mode 100644 index 1bd5c36..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityCustomPageResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.jjikmeok.app.domain.activity.dto.response.page; - -import com.jjikmeok.app.domain.page.dto.response.ActivitySectionResponse; - -import java.util.List; - -public record ActivityCustomPageResponse( - String nickname, - TasteProfile tasteProfile, - ActivitySectionResponse recommended -) { - public record TasteProfile( - String title, - String subtitle, - List hashtags - ) { - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityHomePageResponse.java b/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityHomePageResponse.java deleted file mode 100644 index 102456c..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/page/ActivityHomePageResponse.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.jjikmeok.app.domain.activity.dto.response.page; - -import com.jjikmeok.app.domain.page.dto.response.ActivitySectionResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityShortcutResponse; - -import java.util.List; - -public record ActivityHomePageResponse( - String nickname, - Hero hero, - List shortcuts, - ActivitySectionResponse recommended, - ActivitySectionResponse closingSoon -) { - public record Hero( - String title, - String subtitle, - String actionLabel, - String actionHref - ) { - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/entity/Activity.java b/src/main/java/com/jjikmeok/app/domain/activity/entity/Activity.java index 6000268..aefe7e8 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/entity/Activity.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/entity/Activity.java @@ -5,14 +5,15 @@ import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.jjikmeok.app.domain.region.entity.Region; +import com.jjikmeok.app.domain.tag.entity.Tag; import com.jjikmeok.app.global.common.BaseEntity; import jakarta.persistence.Column; +import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; @@ -82,7 +83,7 @@ public class Activity extends BaseEntity { @Column(nullable = false, length = 50) private ActivityCategory category; - @OneToMany(mappedBy = "activity") + @OneToMany(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true) private List tags = new ArrayList<>(); @Enumerated(EnumType.STRING) @@ -146,7 +147,7 @@ public Activity( this.recruitEndAt = recruitEndAt; this.price = price != null ? price : 0; this.activityType = activityType != null ? activityType : ActivityType.EVENT; - this.category = category != null ? category : ActivityCategory.ETC; + this.category = category != null ? category : ActivityCategory.CULTURE; this.sourceType = sourceType != null ? sourceType : SourceType.URL_MANUAL; this.externalId = externalId; this.approvalStatus = approvalStatus != null ? approvalStatus : ApprovalStatus.PENDING; @@ -163,6 +164,9 @@ public void update( String thumbnailUrl, String sourceUrl, String address, + String organizer, + String contactInfo, + String target, LocalDateTime startAt, LocalDateTime endAt, LocalDateTime recruitStartAt, @@ -181,13 +185,16 @@ public void update( this.thumbnailUrl = thumbnailUrl; this.sourceUrl = sourceUrl; this.address = address; + this.organizer = organizer; + this.contactInfo = contactInfo; + this.target = target; this.startAt = startAt; this.endAt = endAt; this.recruitStartAt = recruitStartAt; this.recruitEndAt = recruitEndAt; this.price = price != null ? price : 0; this.activityType = activityType != null ? activityType : ActivityType.EVENT; - this.category = category != null ? category : ActivityCategory.ETC; + this.category = category != null ? category : ActivityCategory.CULTURE; this.sourceType = sourceType != null ? sourceType : SourceType.URL_MANUAL; this.externalId = externalId; this.approvalStatus = approvalStatus != null ? approvalStatus : ApprovalStatus.PENDING; @@ -210,6 +217,24 @@ public void updateExtra(String organizer, String contactInfo, String target) { this.target = target; } + public void replaceTags(List tags) { + this.tags.clear(); + if (tags == null) { + return; + } + + for (Tag tag : tags) { + addTag(tag); + } + } + + public void addTag(Tag tag) { + if (tag == null) { + return; + } + this.tags.add(ActivityTag.create(this, tag)); + } + public void approve() { this.approvalStatus = ApprovalStatus.APPROVED; this.isActive = endAt == null || !endAt.isBefore(LocalDateTime.now()); diff --git a/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityCategory.java b/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityCategory.java index 700a24b..85ca39a 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityCategory.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityCategory.java @@ -8,20 +8,16 @@ @Getter @RequiredArgsConstructor public enum ActivityCategory { - SPORTS("운동/액티비티"), - CULTURE("문화/공연/축제"), - CRAFT("공예/만들기"), - DANCE("댄스/무용"), - COOKING("요리/베이킹"), - PHOTO_VIDEO("사진/영상"), - MUSIC("음악/악기"), - HUMANITIES("인문학/책/글"), - TRAVEL("여행/산책/탐방"), - LANGUAGE("해외/언어"), + SPORTS("운동 / 액티비티"), + CULTURE("문화 / 예술"), + CRAFT("공예 / 만들기"), + COOKING("요리 / 베이킹"), + PHOTO_VIDEO("사진 / 영상"), + HUMANITIES("책 / 글"), + TRAVEL("여행 / 탐방"), + LANGUAGE("언어 / 해외"), VOLUNTEER("봉사활동"), - SELF_DEVELOPMENT("자기계발/클래스"), - CAREER("커리어/실무"), - ETC("기타"); + CAREER("성장 / 커리어"); private final String label; diff --git a/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityType.java b/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityType.java index ccfe73a..c442747 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityType.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/enums/ActivityType.java @@ -10,7 +10,7 @@ public enum ActivityType { PROGRAM("프로그램"), ONE_DAY("원데이"), - EVENT("행사·강연"), + EVENT("행사/강연"), CLUB("동아리"); private final String label; diff --git a/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTag.java b/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTag.java index 071fe8a..d090ed2 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTag.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTag.java @@ -8,33 +8,39 @@ @Getter @RequiredArgsConstructor public enum PreferenceTag { - CALM("차분", PreferenceTagGroup.MOOD), - LIVELY("활기", PreferenceTagGroup.MOOD), + CALM("편안한", PreferenceTagGroup.MOOD), HEALING("힐링", PreferenceTagGroup.MOOD), - COMFORTABLE("편안", PreferenceTagGroup.MOOD), + LIVELY("활기찬", PreferenceTagGroup.MOOD), + EMOTIONAL("감성적", PreferenceTagGroup.MOOD), + CREATIVE("창의적", PreferenceTagGroup.MOOD), + TRENDY("트렌디", PreferenceTagGroup.MOOD), BEGINNER("입문", PreferenceTagGroup.INTENSITY), LIGHT("가볍게", PreferenceTagGroup.INTENSITY), IMMERSIVE("몰입", PreferenceTagGroup.INTENSITY), CHALLENGE("도전", PreferenceTagGroup.INTENSITY), - FREE("무료", PreferenceTagGroup.PRICE), - PAID("유료", PreferenceTagGroup.PRICE), - REST("휴식", PreferenceTagGroup.PURPOSE), HOBBY("취미", PreferenceTagGroup.PURPOSE), LEARNING("배움", PreferenceTagGroup.PURPOSE), - SOCIAL("사교", PreferenceTagGroup.PURPOSE), GROWTH("성장", PreferenceTagGroup.PURPOSE), - EXPERIENCE("경험", PreferenceTagGroup.PURPOSE), - ONE_DAY("하루", PreferenceTagGroup.DURATION), - THREE_DAYS("3일", PreferenceTagGroup.DURATION), - ONE_WEEK("일주일", PreferenceTagGroup.DURATION), + SHORT_TERM("단기", PreferenceTagGroup.DURATION), ONE_MONTH("한달", PreferenceTagGroup.DURATION), - THREE_MONTHS("3개월", PreferenceTagGroup.DURATION), - OVER_SIX_MONTHS("6개월이상", PreferenceTagGroup.DURATION), - OVER_ONE_YEAR("1년이상", PreferenceTagGroup.DURATION); + SIX_MONTHS("6개월", PreferenceTagGroup.DURATION), + OVER_ONE_YEAR("1년 이상", PreferenceTagGroup.DURATION), + + ONE_DAY("단기", PreferenceTagGroup.DURATION), + THREE_DAYS("단기", PreferenceTagGroup.DURATION), + ONE_WEEK("단기", PreferenceTagGroup.DURATION), + THREE_MONTHS("한달", PreferenceTagGroup.DURATION), + OVER_SIX_MONTHS("6개월", PreferenceTagGroup.DURATION), + + SMALL("소규모", PreferenceTagGroup.SIZE), + LARGE("대규모", PreferenceTagGroup.SIZE), + + SOCIAL("사교", PreferenceTagGroup.PURPOSE), + EXPERIENCE("경험", PreferenceTagGroup.PURPOSE); private final String label; private final PreferenceTagGroup group; diff --git a/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTagGroup.java b/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTagGroup.java index 7b17564..a53d61f 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTagGroup.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/enums/PreferenceTagGroup.java @@ -6,11 +6,11 @@ @Getter @RequiredArgsConstructor public enum PreferenceTagGroup { - MOOD("활동 분위기", "활동에서 느껴지는 전체적인 정서와 무드"), + MOOD("분위기 태그", "활동의 전체적인 분위기를 나타내는 태그"), INTENSITY("활동 강도", "활동에 필요한 부담감, 몰입도, 도전 정도"), - PRICE("활동 금액", "활동 참여 비용 여부"), PURPOSE("활동 목적", "사용자가 활동을 통해 얻고 싶은 것"), - DURATION("활동 기간", "활동이 지속되는 기간"); + DURATION("활동 기간", "활동이 지속되는 기간"), + SIZE("활동 규모", "활동의 참여 규모"); private final String label; private final String description; diff --git a/src/main/java/com/jjikmeok/app/domain/activity/enums/SourceType.java b/src/main/java/com/jjikmeok/app/domain/activity/enums/SourceType.java index e318633..b022527 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/enums/SourceType.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/enums/SourceType.java @@ -1,12 +1,19 @@ package com.jjikmeok.app.domain.activity.enums; public enum SourceType { - TOUR_API, KOPIS, EXHIBITION, - VOLUNTEER_1365, - YOUTH_CONTENT, SEOUL_CULTURE, SEOUL_RESERVATION, + DISCOVERY, URL_MANUAL + + ; + + public boolean isPublicApiSource() { + return this == KOPIS + || this == EXHIBITION + || this == SEOUL_CULTURE + || this == SEOUL_RESERVATION; + } } diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/collector/DiscoveryCollectorService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/collector/DiscoveryCollectorService.java new file mode 100644 index 0000000..a5ef86e --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/collector/DiscoveryCollectorService.java @@ -0,0 +1,307 @@ +package com.jjikmeok.app.domain.activity.privateactivity.collector; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.ai.dto.DiscoveryAnalysisDto; +import com.jjikmeok.app.domain.ai.service.DiscoveryAiAnalysisService; +import com.jjikmeok.app.domain.activity.privateactivity.deduplication.DiscoveryDeduplicationService; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.response.DiscoverySheetRowDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; +import com.jjikmeok.app.domain.activity.privateactivity.enums.RobotsPolicy; +import com.jjikmeok.app.domain.activity.privateactivity.extractor.DiscoveryMetadataExtractorService; +import com.jjikmeok.app.domain.activity.privateactivity.keyword.DiscoveryKeywordService; +import com.jjikmeok.app.domain.activity.privateactivity.search.SearchService; +import com.jjikmeok.app.domain.activity.privateactivity.service.DiscoveryUrlQualityService; +import com.jjikmeok.app.domain.activity.privateactivity.service.RobotsPolicyService; +import com.jjikmeok.app.domain.activity.privateactivity.sheets.GoogleSheetsService; +import com.jjikmeok.app.domain.activity.privateactivity.support.DiscoveryUrlNormalizer; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import com.jjikmeok.app.domain.activity.publicactivity.service.RawActivityArchiveService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscoveryCollectorService { + + private static final double MIN_CONFIDENCE_SCORE = 10.0; + + private final DiscoveryKeywordService keywordService; + private final SearchService searchService; + private final DiscoveryUrlQualityService urlQualityService; + private final RobotsPolicyService robotsPolicyService; + private final DiscoveryMetadataExtractorService metadataExtractorService; + private final DiscoveryAiAnalysisService aiAnalysisService; + private final DiscoveryDeduplicationService deduplicationService; + private final GoogleSheetsService googleSheetsService; + private final DiscoveryUrlNormalizer urlNormalizer; + private final ActivitySyncUtils utils; + private final RawActivityArchiveService rawActivityArchiveService; + + @Value("${app.discovery.analysis.max-ai-analysis-per-run:50}") + private int maxAiAnalysisPerRun; + + public List runAll(int keywordLimit, int resultLimit) { + return run(keywordService.categories(), keywordLimit, resultLimit); + } + + public List run(ActivityCategory category, int keywordLimit, int resultLimit) { + if (category == null) { + return runAll(keywordLimit, resultLimit); + } + return run(List.of(category), keywordLimit, resultLimit); + } + + public List run(List categories, int keywordLimit, int resultLimit) { + List rows = new ArrayList<>(); + Set batchKeys = new HashSet<>(); + Set sourceUrlKeys = existingSourceUrlKeys(); + AtomicInteger aiAnalysisCount = new AtomicInteger(); + + for (ActivityCategory category : categories) { + for (String keyword : keywordsFor(category, keywordLimit)) { + rows.addAll(processKeyword(keyword, resultLimit, batchKeys, sourceUrlKeys, aiAnalysisCount)); + } + } + + return rows; + } + + private List processKeyword( + String keyword, + int resultLimit, + Set batchKeys, + Set sourceUrlKeys, + AtomicInteger aiAnalysisCount + ) { + List searchResults = searchResults(keyword, resultLimit); + log.info("[Discovery] 검색 결과를 불러왔습니다. keyword={}, count={}", keyword, searchResults.size()); + + List rows = new ArrayList<>(); + for (SearchResultDto searchResult : searchResults) { + DiscoverySheetRowDto sheetRow = safeProcessSearchResult( + searchResult, + batchKeys, + sourceUrlKeys, + keyword, + aiAnalysisCount + ); + if (sheetRow != null) { + rows.add(sheetRow); + } + } + return rows; + } + + private DiscoverySheetRowDto safeProcessSearchResult( + SearchResultDto searchResult, + Set batchKeys, + Set sourceUrlKeys, + String keyword, + AtomicInteger aiAnalysisCount + ) { + try { + return processSearchResult(searchResult, batchKeys, sourceUrlKeys, keyword, aiAnalysisCount); + } catch (Exception e) { + log.warn( + "[Discovery] 후보 처리에 실패했습니다. keyword={}, url={}, reason={}", + keyword, + searchResult == null ? null : searchResult.url(), + e.getMessage(), + e + ); + return null; + } + } + + private DiscoverySheetRowDto processSearchResult( + SearchResultDto searchResult, + Set batchKeys, + Set sourceUrlKeys, + String keyword, + AtomicInteger aiAnalysisCount + ) { + if (searchResult == null) { + return null; + } + + String searchKey = key("search", searchResult.url(), searchResult.title(), null); + if (!batchKeys.add(searchKey)) { + return null; + } + + var assessment = urlQualityService.evaluate(searchResult); + SearchResultDto classifiedSearchResult = searchResult.withSourceChannel(assessment.sourceChannel()); + if (assessment.excluded()) { + log.info("[Discovery] 품질 필터로 URL을 제외했습니다. platform={}, url={}", assessment.platform(), searchResult.url()); + return null; + } + + ExtractionMode extractionMode = extractionMode(classifiedSearchResult, assessment.extractionMode()); + if (extractionMode == null) { + return null; + } + + DiscoveryCandidateDto candidate = extractCandidate(classifiedSearchResult, extractionMode, assessment.confidenceScore()); + if (candidate == null || candidate.confidenceScore() < MIN_CONFIDENCE_SCORE) { + log.info("[Discovery] 후보 신뢰도가 낮아 제외했습니다. url={}", searchResult.url()); + return null; + } + + String sourceUrlKey = normalizedUrlKey(candidate.sourceUrl()); + if (sourceUrlKey == null) { + log.info("[Discovery] sourceUrl 이 없습니다. keyword={}, title={}", keyword, candidate.title()); + return null; + } + if (!sourceUrlKeys.add(sourceUrlKey)) { + log.info("[Discovery] 이미 처리한 sourceUrl 입니다. url={}", candidate.sourceUrl()); + return null; + } + + String candidateKey = key("candidate", candidate.sourceUrl(), candidate.title(), candidate.organizer()); + if (!batchKeys.add(candidateKey)) { + return null; + } + + rawActivityArchiveService.archiveDiscoveryCandidate(classifiedSearchResult, candidate); + + if (deduplicationService.findDuplicateReason(candidate.sourceUrl(), candidate.title(), candidate.organizer()).isPresent()) { + log.info("[Discovery] 저장된 활동과 중복되어 제외했습니다. url={}", candidate.sourceUrl()); + return null; + } + + DiscoverySheetRowDto sheetRow = appendCandidate(candidate); + if (sheetRow == null) { + return null; + } + + if (aiAnalysisCount.get() >= maxAiAnalysisPerRun) { + log.info("[Discovery] AI 분석 한도에 도달했습니다. url={}", candidate.sourceUrl()); + return sheetRow; + } + + return analyzeCandidate(sheetRow, candidate, aiAnalysisCount); + } + + private ExtractionMode extractionMode(SearchResultDto searchResult, ExtractionMode defaultMode) { + RobotsPolicy robotsPolicy = robotsPolicyService.evaluate(uri(searchResult.url())); + if (robotsPolicy == RobotsPolicy.DISALLOWED) { + log.info("[Discovery] robots.txt 에서 차단된 URL 입니다. url={}", searchResult.url()); + return null; + } + if (robotsPolicy == RobotsPolicy.UNKNOWN && defaultMode == ExtractionMode.FULL_CONTENT) { + return ExtractionMode.METADATA_ONLY; + } + return defaultMode; + } + + private List keywordsFor(ActivityCategory category, int keywordLimit) { + try { + return keywordService.keywordsFor(category, keywordLimit); + } catch (Exception e) { + log.warn("[Discovery] 키워드를 불러오지 못했습니다. category={}, reason={}", category, e.getMessage(), e); + return List.of(); + } + } + + private List searchResults(String keyword, int resultLimit) { + try { + return searchService.search(keyword, resultLimit); + } catch (Exception e) { + log.warn("[Discovery] 검색 요청에 실패했습니다. keyword={}, reason={}", keyword, e.getMessage(), e); + return List.of(); + } + } + + private DiscoveryCandidateDto extractCandidate( + SearchResultDto searchResult, + ExtractionMode extractionMode, + double confidenceScore + ) { + try { + return metadataExtractorService.extract(searchResult, extractionMode, confidenceScore); + } catch (Exception e) { + log.warn("[Discovery] 메타데이터 추출에 실패했습니다. url={}, reason={}", searchResult.url(), e.getMessage(), e); + return null; + } + } + + private DiscoverySheetRowDto appendCandidate(DiscoveryCandidateDto candidate) { + try { + return googleSheetsService.append(candidate); + } catch (Exception e) { + log.warn("[Discovery] 후보를 시트에 추가하지 못했습니다. url={}, reason={}", candidate.sourceUrl(), e.getMessage(), e); + return null; + } + } + + private DiscoverySheetRowDto analyzeCandidate( + DiscoverySheetRowDto sheetRow, + DiscoveryCandidateDto candidate, + AtomicInteger aiAnalysisCount + ) { + try { + aiAnalysisCount.incrementAndGet(); + DiscoveryAnalysisDto analysis = aiAnalysisService.analyze(candidate); + if (analysis == null) { + return sheetRow; + } + + DiscoverySheetRowDto analyzedRow = sheetRow.withAnalysis(analysis); + googleSheetsService.updateRow(analyzedRow); + return analyzedRow; + } catch (Exception e) { + log.warn("[Discovery] AI 분석에 실패했습니다. url={}, reason={}", candidate.sourceUrl(), e.getMessage(), e); + return sheetRow; + } + } + + private Set existingSourceUrlKeys() { + Set keys = new HashSet<>(); + try { + for (DiscoverySheetRowDto row : googleSheetsService.snapshot()) { + String key = normalizedUrlKey(row == null ? null : row.sourceUrl()); + if (key != null) { + keys.add(key); + } + } + } catch (Exception e) { + log.warn("[Discovery] 기존 시트 URL을 불러오지 못했습니다. reason={}", e.getMessage(), e); + } + return keys; + } + + private String normalizedUrlKey(String sourceUrl) { + String normalizedUrl = urlNormalizer.normalize(sourceUrl); + return utils.isBlank(normalizedUrl) ? null : normalizedUrl; + } + + private String key(String prefix, String sourceUrl, String title, String organizer) { + String normalizedUrl = urlNormalizer.normalize(sourceUrl); + String normalizedTitle = utils.cleanText(title); + String normalizedOrganizer = utils.cleanText(organizer); + return prefix + ":" + + (utils.isBlank(normalizedUrl) ? "-" : normalizedUrl) + "|" + + (utils.isBlank(normalizedTitle) ? "-" : normalizedTitle) + "|" + + (utils.isBlank(normalizedOrganizer) ? "-" : normalizedOrganizer); + } + + private URI uri(String value) { + try { + return value == null || value.isBlank() ? null : URI.create(value); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/deduplication/DiscoveryDeduplicationService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/deduplication/DiscoveryDeduplicationService.java new file mode 100644 index 0000000..bc1f264 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/deduplication/DiscoveryDeduplicationService.java @@ -0,0 +1,151 @@ +package com.jjikmeok.app.domain.activity.privateactivity.deduplication; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.repository.ActivityRepository; +import com.jjikmeok.app.domain.ai.dto.DiscoveryAnalysisDto; +import com.jjikmeok.app.domain.activity.privateactivity.support.DiscoveryUrlNormalizer; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.Normalizer; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class DiscoveryDeduplicationService { + + private final ActivityRepository activityRepository; + private final DiscoveryUrlNormalizer urlNormalizer; + private final ActivitySyncUtils utils; + + public boolean isDuplicate(DiscoveryAnalysisDto analysis) { + return findDuplicateReason( + analysis.sourceUrl(), + analysis.title(), + analysis.organizer() + ).isPresent(); + } + + public Optional findDuplicateReason(String sourceUrl, String title, String organizer) { + String normalizedUrl = urlNormalizer.normalize(sourceUrl); + if (!utils.isBlank(normalizedUrl) && activityRepository.existsBySourceUrl(normalizedUrl)) { + return Optional.of("sourceUrl 중복"); + } + + String normalizedTitle = normalizeText(title); + String normalizedOrganizer = normalizeText(organizer); + if (!utils.isBlank(normalizedTitle) && !utils.isBlank(normalizedOrganizer)) { + Optional exactMatch = activityRepository.findFirstByTitleIgnoreCaseAndOrganizerIgnoreCase( + normalizedTitle, + normalizedOrganizer + ); + if (exactMatch.isPresent()) { + return Optional.of("title + organizer 중복"); + } + + String titleToken = trimmedToken(normalizedTitle); + String organizerToken = trimmedToken(normalizedOrganizer); + if (!utils.isBlank(titleToken) && !utils.isBlank(organizerToken)) { + List candidates = activityRepository.findTop50ByTitleContainingIgnoreCaseOrOrganizerContainingIgnoreCaseOrderByCreatedAtDesc( + titleToken, + organizerToken + ); + for (Activity candidate : candidates) { + double titleScore = similarity(normalizedTitle, normalizeText(candidate.getTitle())); + double organizerScore = similarity(normalizedOrganizer, normalizeText(candidate.getOrganizer())); + if (titleScore >= 0.86 && organizerScore >= 0.86) { + return Optional.of("title + organizer 유사 중복"); + } + } + } + } + + String externalId = createDiscoveryExternalId(normalizedUrl, sourceUrl, title); + return activityRepository.findDuplicate( + SourceType.DISCOVERY, + externalId, + normalizedUrl, + title, + null, + null + ).map(activity -> "기존 데이터 중복: " + activity.getId()); + } + + private String normalizeText(String value) { + if (value == null) { + return null; + } + String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC) + .toLowerCase() + .replaceAll("[^\\p{IsAlphabetic}\\p{IsDigit}\\s]", " ") + .replaceAll("\\s+", " ") + .trim(); + return normalized.isBlank() ? null : normalized; + } + + private String trimmedToken(String value) { + if (utils.isBlank(value)) { + return ""; + } + return value.length() > 20 ? value.substring(0, 20) : value; + } + + private double similarity(String left, String right) { + if (utils.isBlank(left) || utils.isBlank(right)) { + return 0.0; + } + if (left.equals(right)) { + return 1.0; + } + int distance = levenshtein(left, right); + int max = Math.max(left.length(), right.length()); + return max == 0 ? 1.0 : 1.0 - ((double) distance / max); + } + + private int levenshtein(String left, String right) { + int[] prev = new int[right.length() + 1]; + int[] curr = new int[right.length() + 1]; + for (int j = 0; j <= right.length(); j++) { + prev[j] = j; + } + + for (int i = 1; i <= left.length(); i++) { + curr[0] = i; + for (int j = 1; j <= right.length(); j++) { + int cost = left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + Math.min(curr[j - 1] + 1, prev[j] + 1), + prev[j - 1] + cost + ); + } + int[] swap = prev; + prev = curr; + curr = swap; + } + return prev[right.length()]; + } + + private String createDiscoveryExternalId(String normalizedUrl, String sourceUrl, String title) { + String sourceKey = normalizedUrl != null ? normalizedUrl : (sourceUrl == null ? "" : sourceUrl); + return hashExternalIdSeed(sourceKey + "|" + title); + } + + private String hashExternalIdSeed(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 12 && i < hash.length; i++) { + builder.append(String.format("%02x", hash[i])); + } + return builder.toString(); + } catch (Exception e) { + return Integer.toHexString((value == null ? "" : value).hashCode()); + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/DiscoveryCandidateDto.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/DiscoveryCandidateDto.java new file mode 100644 index 0000000..41a8419 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/DiscoveryCandidateDto.java @@ -0,0 +1,27 @@ +package com.jjikmeok.app.domain.activity.privateactivity.dto; + +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; + +import java.time.LocalDateTime; + +public record DiscoveryCandidateDto( + String keyword, + SearchResultDto searchResult, + String title, + String sourceUrl, + String thumbnailUrl, + String description, + String organizer, + String contactInfo, + String target, + String address, + LocalDateTime startAt, + LocalDateTime endAt, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + Integer price, + ExtractionMode extractionMode, + double confidenceScore, + String pageText +) { +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/SearchResultDto.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/SearchResultDto.java new file mode 100644 index 0000000..7abbbb5 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/SearchResultDto.java @@ -0,0 +1,17 @@ +package com.jjikmeok.app.domain.activity.privateactivity.dto; + +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySourceChannel; + +public record SearchResultDto( + String keyword, + String title, + String url, + String snippet, + Integer position, + String provider, + DiscoverySourceChannel sourceChannel +) { + public SearchResultDto withSourceChannel(DiscoverySourceChannel sourceChannel) { + return new SearchResultDto(keyword, title, url, snippet, position, provider, sourceChannel); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/response/DiscoverySheetRowDto.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/response/DiscoverySheetRowDto.java new file mode 100644 index 0000000..3a747ac --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/dto/response/DiscoverySheetRowDto.java @@ -0,0 +1,406 @@ +package com.jjikmeok.app.domain.activity.privateactivity.dto.response; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.ai.dto.DiscoveryAnalysisDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryDuration; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryGroupSize; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryIntensity; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryMood; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryPurpose; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySheetStatus; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySourceChannel; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +public record DiscoverySheetRowDto( + int rowNumber, + DiscoverySheetStatus status, + LocalDateTime createdAt, + LocalDateTime publishedAt, + String keyword, + String sourceName, + String title, + String sourceUrl, + String thumbnailUrl, + ActivityType activityType, + ActivityCategory category, + LocalDateTime startAt, + LocalDateTime endAt, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + String target, + Integer price, + String description, + String contactInfo, + String organizer, + String address, + DiscoveryMood moodTag1, + DiscoveryMood moodTag2, + DiscoveryIntensity intensity, + DiscoveryPurpose purpose, + DiscoveryDuration duration, + DiscoveryGroupSize groupSize, + Double confidenceScore, + String searchSnippet +) { + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + public static DiscoverySheetRowDto fromCandidate(DiscoveryCandidateDto candidate, int rowNumber, LocalDateTime createdAt) { + return new DiscoverySheetRowDto( + rowNumber, + DiscoverySheetStatus.PENDING, + createdAt, + null, + candidate.keyword(), + candidate.searchResult() == null || candidate.searchResult().sourceChannel() == null + ? DiscoverySourceChannel.WEBSITE.name() + : candidate.searchResult().sourceChannel().name(), + candidate.title(), + candidate.sourceUrl(), + candidate.thumbnailUrl(), + null, + null, + candidate.startAt(), + candidate.endAt(), + candidate.recruitStartAt(), + candidate.recruitEndAt(), + candidate.target(), + candidate.price(), + candidate.description(), + candidate.contactInfo(), + candidate.organizer(), + candidate.address(), + null, + null, + null, + null, + null, + null, + candidate.confidenceScore(), + candidate.searchResult() == null ? null : candidate.searchResult().snippet() + ); + } + + public static DiscoverySheetRowDto from(DiscoveryAnalysisDto analysis, int rowNumber, LocalDateTime createdAt) { + return new DiscoverySheetRowDto( + rowNumber, + DiscoverySheetStatus.PENDING, + createdAt, + null, + analysis.keyword(), + analysis.sourceName(), + analysis.title(), + analysis.sourceUrl(), + analysis.thumbnailUrl(), + analysis.activityType(), + analysis.category(), + analysis.startAt(), + analysis.endAt(), + analysis.recruitStartAt(), + analysis.recruitEndAt(), + analysis.target(), + analysis.price(), + analysis.description(), + analysis.contactInfo(), + analysis.organizer(), + analysis.address(), + analysis.moodTag1(), + analysis.moodTag2(), + analysis.intensity(), + analysis.purpose(), + analysis.duration(), + analysis.groupSize(), + analysis.confidenceScore(), + analysis.searchSnippet() + ); + } + + public static DiscoverySheetRowDto fromPublicActivity(NormalizedActivity activity, int rowNumber, LocalDateTime createdAt) { + return new DiscoverySheetRowDto( + rowNumber, + DiscoverySheetStatus.PUBLISHED, + createdAt, + createdAt, + null, + activity.sourceType() == null ? null : activity.sourceType().name(), + activity.title(), + activity.sourceUrl(), + activity.thumbnailUrl(), + activity.activityType(), + activity.category(), + activity.startAt(), + activity.endAt(), + activity.recruitStartAt(), + activity.recruitEndAt(), + activity.target(), + activity.price(), + activity.description(), + activity.contactInfo(), + activity.organizer(), + activity.address(), + null, + null, + null, + null, + null, + null, + 100d, + null + ); + } + + public DiscoverySheetRowDto withAnalysis(DiscoveryAnalysisDto analysis) { + if (analysis == null) { + return this; + } + + return new DiscoverySheetRowDto( + rowNumber, + status, + createdAt, + publishedAt, + firstText(keyword, analysis.keyword()), + firstText(sourceName, analysis.sourceName()), + firstText(title, analysis.title()), + firstText(sourceUrl, analysis.sourceUrl()), + firstText(thumbnailUrl, analysis.thumbnailUrl()), + activityType != null ? activityType : analysis.activityType(), + category != null ? category : analysis.category(), + firstDate(startAt, analysis.startAt()), + firstDate(endAt, analysis.endAt()), + firstDate(recruitStartAt, analysis.recruitStartAt()), + firstDate(recruitEndAt, analysis.recruitEndAt()), + firstText(target, analysis.target()), + price != null ? price : analysis.price(), + firstText(description, analysis.description()), + firstText(contactInfo, analysis.contactInfo()), + firstText(organizer, analysis.organizer()), + firstText(address, analysis.address()), + moodTag1 != null ? moodTag1 : analysis.moodTag1(), + moodTag2 != null ? moodTag2 : analysis.moodTag2(), + intensity != null ? intensity : analysis.intensity(), + purpose != null ? purpose : analysis.purpose(), + duration != null ? duration : analysis.duration(), + groupSize != null ? groupSize : analysis.groupSize(), + confidenceScore != null ? confidenceScore : analysis.confidenceScore(), + firstText(searchSnippet, analysis.searchSnippet()) + ); + } + + public DiscoverySheetRowDto withStatus(DiscoverySheetStatus status, LocalDateTime publishedAt) { + return new DiscoverySheetRowDto( + rowNumber, + status, + createdAt, + publishedAt, + keyword, + sourceName, + title, + sourceUrl, + thumbnailUrl, + activityType, + category, + startAt, + endAt, + recruitStartAt, + recruitEndAt, + target, + price, + description, + contactInfo, + organizer, + address, + moodTag1, + moodTag2, + intensity, + purpose, + duration, + groupSize, + confidenceScore, + searchSnippet + ); + } + + public List toSheetRow() { + List values = new ArrayList<>(); + values.add(rowNumber); + values.add(status == null ? DiscoverySheetStatus.PENDING.name() : status.name()); + values.add(format(createdAt)); + values.add(format(publishedAt)); + values.add(keyword); + values.add(sourceName); + values.add(title); + values.add(sourceUrl); + values.add(thumbnailUrl); + values.add(enumLabel(activityType)); + values.add(enumLabel(category)); + values.add(format(startAt)); + values.add(format(endAt)); + values.add(format(recruitStartAt)); + values.add(format(recruitEndAt)); + values.add(target); + values.add(price); + values.add(description); + values.add(contactInfo); + values.add(organizer); + values.add(address); + values.add(enumLabel(moodTag1)); + values.add(enumLabel(moodTag2)); + values.add(enumLabel(intensity)); + values.add(enumLabel(purpose)); + values.add(enumLabel(duration)); + values.add(enumLabel(groupSize)); + values.add(confidenceScore); + values.add(searchSnippet); + return values; + } + + public static DiscoverySheetRowDto fromSheetRow(int rowNumber, List values) { + return new DiscoverySheetRowDto( + rowNumber, + parseStatus(text(values, 1)), + parseDateTime(text(values, 2)), + parseDateTime(text(values, 3)), + text(values, 4), + text(values, 5), + text(values, 6), + text(values, 7), + text(values, 8), + parseEnum(text(values, 9), ActivityType.class), + parseActivityCategory(text(values, 10)), + parseDateTime(text(values, 11)), + parseDateTime(text(values, 12)), + parseDateTime(text(values, 13)), + parseDateTime(text(values, 14)), + text(values, 15), + parseInteger(text(values, 16)), + text(values, 17), + text(values, 18), + text(values, 19), + text(values, 20), + parseEnum(text(values, 21), DiscoveryMood.class), + parseEnum(text(values, 22), DiscoveryMood.class), + parseEnum(text(values, 23), DiscoveryIntensity.class), + parseEnum(text(values, 24), DiscoveryPurpose.class), + parseEnum(text(values, 25), DiscoveryDuration.class), + parseEnum(text(values, 26), DiscoveryGroupSize.class), + parseDouble(text(values, 27)), + text(values, 28) + ); + } + + public static String[] sheetHeaders() { + return new String[] { + "ID", "상태", "수집일", "확인일", "수집 키워드", "운영처 / 플랫폼", "활동명", "링크", "썸네일", + "활동 분야", "주제 카테고리", "활동기간 시작", "활동기간 종료", "모집기간 시작", "모집기간 종료", + "대상", "금액", "설명", "문의처", "주최처", "지역 / 장소", "분위기 태그 1", "분위기 태그 2", + "강도 태그", "목적 태그", "기간 태그", "규모 태그", "신뢰도", "분류 근거" + }; + } + + private static DiscoverySheetStatus parseStatus(String value) { + if (value == null || value.isBlank()) { + return DiscoverySheetStatus.PENDING; + } + try { + return DiscoverySheetStatus.valueOf(value.trim().toUpperCase()); + } catch (Exception e) { + return DiscoverySheetStatus.PENDING; + } + } + + private static LocalDateTime parseDateTime(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return LocalDateTime.parse(value.trim(), FORMATTER); + } catch (Exception e) { + return null; + } + } + + private static String format(LocalDateTime value) { + return value == null ? null : FORMATTER.format(value); + } + + private static String text(List values, int index) { + if (values == null || index < 0 || index >= values.size()) { + return null; + } + Object value = values.get(index); + return value == null ? null : value.toString(); + } + + private static Integer parseInteger(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Integer.parseInt(value.trim()); + } catch (Exception e) { + return null; + } + } + + private static Double parseDouble(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return Double.parseDouble(value.trim()); + } catch (Exception e) { + return null; + } + } + + private static ActivityCategory parseActivityCategory(String value) { + return parseEnum(value, ActivityCategory.class); + } + + private static > E parseEnum(String value, Class enumType) { + if (value == null || value.isBlank()) { + return null; + } + + String trimmed = value.trim(); + for (E constant : enumType.getEnumConstants()) { + if (constant.name().equalsIgnoreCase(trimmed)) { + return constant; + } + String label = enumLabel(constant); + if (label != null && label.equalsIgnoreCase(trimmed)) { + return constant; + } + } + return null; + } + + private static LocalDateTime firstDate(LocalDateTime first, LocalDateTime second) { + return first != null ? first : second; + } + + private static String firstText(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } + + private static String enumLabel(Enum value) { + if (value == null) { + return null; + } + try { + Method method = value.getClass().getMethod("getLabel"); + Object label = method.invoke(value); + return label == null ? null : label.toString(); + } catch (Exception e) { + return value.name(); + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryDuration.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryDuration.java new file mode 100644 index 0000000..d141f42 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryDuration.java @@ -0,0 +1,15 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscoveryDuration { + SHORT_TERM("단기"), + ONE_MONTH("한달"), + SIX_MONTHS("6개월"), + OVER_ONE_YEAR("1년 이상"); + + private final String label; +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryGroupSize.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryGroupSize.java new file mode 100644 index 0000000..800e372 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryGroupSize.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscoveryGroupSize { + SMALL("소규모"), + LARGE("대규모"); + + private final String label; +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryIntensity.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryIntensity.java new file mode 100644 index 0000000..521ecb4 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryIntensity.java @@ -0,0 +1,15 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscoveryIntensity { + BEGINNER("입문"), + LIGHT("가볍게"), + IMMERSIVE("몰입"), + CHALLENGE("도전"); + + private final String label; +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryMood.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryMood.java new file mode 100644 index 0000000..3816bbc --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryMood.java @@ -0,0 +1,17 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscoveryMood { + CALM("편안한"), + HEALING("힐링"), + LIVELY("활기찬"), + EMOTIONAL("감성적"), + CREATIVE("창의적"), + TRENDY("트렌디"); + + private final String label; +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryPurpose.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryPurpose.java new file mode 100644 index 0000000..e55775c --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoveryPurpose.java @@ -0,0 +1,15 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DiscoveryPurpose { + REST("휴식"), + HOBBY("취미"), + LEARNING("배움"), + GROWTH("성장"); + + private final String label; +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySheetStatus.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySheetStatus.java new file mode 100644 index 0000000..c103f96 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySheetStatus.java @@ -0,0 +1,15 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +public enum DiscoverySheetStatus { + PENDING, + REVIEWING, + READY, + PUBLISHED, + DUPLICATE, + REJECTED, + ERROR; + + public boolean isTerminal() { + return this == PUBLISHED || this == DUPLICATE || this == REJECTED || this == ERROR; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySourceChannel.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySourceChannel.java new file mode 100644 index 0000000..92a11e7 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/DiscoverySourceChannel.java @@ -0,0 +1,16 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +public enum DiscoverySourceChannel { + KOPIS, + EXHIBITION, + SEOUL_CULTURE, + SEOUL_RESERVATION, + INSTAGRAM, + NAVER_BLOG, + NAVER_CAFE, + BAND, + BRUNCH, + TISTORY, + NOTION, + WEBSITE +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/ExtractionMode.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/ExtractionMode.java new file mode 100644 index 0000000..7e4d375 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/ExtractionMode.java @@ -0,0 +1,7 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +public enum ExtractionMode { + URL_ONLY, + METADATA_ONLY, + FULL_CONTENT +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/RobotsPolicy.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/RobotsPolicy.java new file mode 100644 index 0000000..0e84361 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/enums/RobotsPolicy.java @@ -0,0 +1,7 @@ +package com.jjikmeok.app.domain.activity.privateactivity.enums; + +public enum RobotsPolicy { + ALLOWED, + DISALLOWED, + UNKNOWN +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/extractor/DiscoveryMetadataExtractorService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/extractor/DiscoveryMetadataExtractorService.java new file mode 100644 index 0000000..1121f77 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/extractor/DiscoveryMetadataExtractorService.java @@ -0,0 +1,607 @@ +package com.jjikmeok.app.domain.activity.privateactivity.extractor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; +import com.jjikmeok.app.domain.activity.privateactivity.support.DiscoveryUrlNormalizer; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.HtmlUtils; + +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscoveryMetadataExtractorService { + + private final ActivitySyncUtils utils; + private final DiscoveryUrlNormalizer urlNormalizer; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final RestClient restClient = RestClient.create(); + + public DiscoveryCandidateDto extract(SearchResultDto searchResult) { + return extract(searchResult, ExtractionMode.FULL_CONTENT, 20d); + } + + public DiscoveryCandidateDto extract(SearchResultDto searchResult, ExtractionMode extractionMode, double confidenceScore) { + String sourceUrl = urlNormalizer.normalize(searchResult.url()); + if (utils.isBlank(sourceUrl)) { + sourceUrl = searchResult.url(); + } + + if (extractionMode == ExtractionMode.URL_ONLY) { + return urlOnly(searchResult, sourceUrl, confidenceScore); + } + + String html = fetchHtml(sourceUrl); + if (utils.isBlank(html)) { + return fallback(searchResult, sourceUrl, null, extractionMode, confidenceScore); + } + + String visibleText = strip(html); + JsonNode jsonLd = jsonLd(html); + String thumbnailUrl = first( + absoluteUrl(sourceUrl, jsonImage(jsonLd)), + absoluteUrl(sourceUrl, meta(html, "og:image")), + absoluteUrl(sourceUrl, meta(html, "twitter:image")), + firstImage(html) + ); + thumbnailUrl = first(thumbnailUrl, faviconUrl(html, sourceUrl)); + + String title = utils.cleanTitle(first( + jsonText(jsonLd, "name"), + meta(html, "og:title"), + meta(html, "twitter:title"), + headingTitle(html), + htmlTitle(html), + searchResult.title() + )); + + String description = utils.cleanDescriptionBody(first( + jsonText(jsonLd, "description"), + meta(html, "og:description"), + meta(html, "twitter:description"), + meta(html, "description"), + searchResult.snippet() + )); + + if (extractionMode == ExtractionMode.METADATA_ONLY) { + return new DiscoveryCandidateDto( + searchResult.keyword(), + searchResult, + title, + sourceUrl, + thumbnailUrl, + description, + null, + null, + null, + null, + null, + null, + null, + null, + null, + extractionMode, + confidenceScore, + compact(first(visibleText, searchResult.snippet(), description, title)) + ); + } + + String address = first( + utils.cleanAddressStrict(jsonLocation(jsonLd)), + utils.cleanAddressStrict(labeledValue(visibleText, "??", "???", "??", "LOCATION")), + utils.cleanAddressStrict(searchResult.snippet()) + ); + + String organizer = cleanOrganizer(first( + jsonNamedValue(jsonLd, "organizer"), + jsonNamedValue(jsonLd, "provider"), + labeledValue(visibleText, "??", "??", "??", "ORGANIZER", "PROVIDER") + )); + + String contactInfo = utils.contactOnly(first( + labeledValue(visibleText, "??", "???", "??", "CONTACT"), + searchResult.snippet(), + visibleText + )); + + String target = first( + labeledValue(visibleText, "??", "????", "????", "????", "????", "????"), + utils.extractTargetFromMixedText(visibleText) + ); + + ActivitySyncUtils.DateRange recruitRange = utils.extractDateRangeFromMixedText(first( + labeledValue(visibleText, "????", "????", "????", "?? ??", "?? ??"), + searchResult.snippet() + )); + + ActivitySyncUtils.DateRange activityRange = utils.extractDateRangeFromMixedText(first( + labeledValue(visibleText, "????", "????", "????", "????", "??????", "??"), + searchResult.snippet(), + visibleText + )); + + LocalDateTime startAt = first( + jsonDate(jsonLd, "startDate"), + activityRange.start(), + utils.extractSingleDateTime(visibleText) + ); + + LocalDateTime endAt = first( + jsonDate(jsonLd, "endDate"), + activityRange.end(), + startAt + ); + + LocalDateTime recruitStartAt = first( + jsonDate(jsonLd, "registrationStartDate"), + jsonDate(jsonLd, "validFrom"), + recruitRange.start() + ); + + LocalDateTime recruitEndAt = first( + jsonDate(jsonLd, "registrationEndDate"), + jsonDate(jsonLd, "validThrough"), + recruitRange.end() + ); + + Integer price = firstPrice( + jsonPrice(jsonLd), + utils.extractPriceFromText(description, true), + utils.extractPriceFromText(visibleText, true), + utils.extractPriceFromText(searchResult.snippet(), true) + ); + + String pageText = compact(first(visibleText, searchResult.snippet(), description, title)); + return new DiscoveryCandidateDto( + searchResult.keyword(), + searchResult, + title, + sourceUrl, + thumbnailUrl, + description, + organizer, + contactInfo, + target, + address, + startAt, + endAt, + recruitStartAt, + recruitEndAt, + price, + extractionMode, + confidenceScore, + pageText + ); + } + + private DiscoveryCandidateDto fallback(SearchResultDto searchResult, String sourceUrl, String html, ExtractionMode extractionMode, double confidenceScore) { + String visibleText = html == null ? searchResult.snippet() : strip(html); + String thumbnailUrl = first(null, faviconUrl(html, sourceUrl)); + return new DiscoveryCandidateDto( + searchResult.keyword(), + searchResult, + utils.cleanTitle(searchResult.title()), + sourceUrl, + thumbnailUrl, + utils.cleanDescriptionBody(searchResult.snippet()), + null, + null, + null, + null, + null, + null, + null, + null, + null, + extractionMode, + confidenceScore, + compact(visibleText) + ); + } + + private DiscoveryCandidateDto urlOnly(SearchResultDto searchResult, String sourceUrl, double confidenceScore) { + String thumbnailUrl = faviconUrl(null, sourceUrl); + return new DiscoveryCandidateDto( + searchResult.keyword(), + searchResult, + utils.cleanTitle(first(searchResult.title(), sourceUrl)), + sourceUrl, + thumbnailUrl, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + ExtractionMode.URL_ONLY, + confidenceScore, + compact(first(searchResult.title(), searchResult.snippet(), sourceUrl)) + ); + } + + private String fetchHtml(String sourceUrl) { + + if (utils.isBlank(sourceUrl)) { + return null; + } + + try { + return restClient.get() + .uri(URI.create(sourceUrl)) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36") + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + .retrieve() + .body(String.class); + } catch (Exception e) { + log.debug("[발견] 메타데이터 조회에 실패했습니다. URL={}", sourceUrl, e); + return null; + } + } + + private JsonNode jsonLd(String html) { + Matcher matcher = Pattern.compile("]+type=[\"']application/ld\\+json[\"'][^>]*>(.*?)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(html); + while (matcher.find()) { + try { + JsonNode node = objectMapper.readTree(matcher.group(1).trim()); + JsonNode picked = pickJsonLd(node); + if (picked != null) { + return picked; + } + } catch (Exception ignored) { + } + } + return null; + } + + private JsonNode pickJsonLd(JsonNode node) { + if (node == null) { + return null; + } + if (node.isArray()) { + for (JsonNode child : node) { + JsonNode picked = pickJsonLd(child); + if (picked != null) { + return picked; + } + } + return null; + } + JsonNode graph = node.path("@graph"); + if (graph.isArray()) { + return pickJsonLd(graph); + } + return node.isObject() ? node : null; + } + + private String jsonText(JsonNode node, String name) { + JsonNode child = child(node, name); + return child != null && child.isValueNode() && !child.asText().isBlank() ? utils.cleanText(child.asText()) : null; + } + + private String jsonImage(JsonNode node) { + JsonNode image = child(node, "image"); + if (image == null || image.isNull()) { + return null; + } + if (image.isTextual()) { + return utils.cleanText(image.asText()); + } + if (image.isArray()) { + for (JsonNode item : image) { + String value = item.isTextual() ? item.asText() : first(jsonText(item, "url"), jsonText(item, "contentUrl")); + if (!utils.isBlank(value)) { + return value; + } + } + return null; + } + return first(jsonText(image, "url"), jsonText(image, "contentUrl")); + } + + private String jsonLocation(JsonNode node) { + JsonNode location = child(node, "location"); + if (location == null || location.isNull()) { + return null; + } + return first(jsonText(location, "name"), jsonText(child(location, "address"), "streetAddress"), jsonText(location, "address")); + } + + private String jsonNamedValue(JsonNode node, String name) { + JsonNode value = child(node, name); + if (value == null || value.isNull()) { + return null; + } + if (value.isArray() && !value.isEmpty()) { + value = value.get(0); + } + if (value.isTextual()) { + return utils.cleanText(value.asText()); + } + return first(jsonText(value, "name"), jsonText(value, "legalName"), jsonText(value, "url")); + } + + private String jsonPrice(JsonNode node) { + JsonNode offers = child(node, "offers"); + if (offers == null || offers.isNull()) { + return null; + } + if (offers.isArray() && !offers.isEmpty()) { + offers = offers.get(0); + } + return first(jsonText(offers, "price"), jsonText(offers, "lowPrice")); + } + + private LocalDateTime jsonDate(JsonNode node, String name) { + String value = jsonText(node, name); + if (utils.isBlank(value)) { + return null; + } + + try { + return LocalDateTime.parse(value); + } catch (RuntimeException ignored) { + } + + try { + return java.time.OffsetDateTime.parse(value).toLocalDateTime(); + } catch (RuntimeException ignored) { + } + + String normalized = value.replace('/', '-').replace('.', '-').trim(); + if (normalized.matches("\\d{8}")) { + return LocalDate.parse(normalized, DateTimeFormatter.BASIC_ISO_DATE).atStartOfDay(); + } + if (normalized.matches("\\d{4}-\\d{1,2}-\\d{1,2}")) { + String[] parts = normalized.split("-"); + return LocalDate.of(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2])).atStartOfDay(); + } + if (normalized.matches("\\d{4}-\\d{1,2}-\\d{1,2} \\d{1,2}:\\d{2}(:\\d{2})?")) { + String[] parts = normalized.split(" ", 2); + String time = parts[1]; + if (time.length() == 5) { + time = time + ":00"; + } + String[] dateParts = parts[0].split("-"); + return LocalDate.of(Integer.parseInt(dateParts[0]), Integer.parseInt(dateParts[1]), Integer.parseInt(dateParts[2])) + .atTime(Integer.parseInt(time.substring(0, 2)), Integer.parseInt(time.substring(3, 5)), Integer.parseInt(time.substring(6, 8))); + } + return utils.extractSingleDateTime(value); + } + + private JsonNode child(JsonNode node, String name) { + if (node == null || !node.isObject()) { + return null; + } + JsonNode direct = node.path(name); + if (!direct.isMissingNode()) { + return direct; + } + for (var iterator = node.fields(); iterator.hasNext(); ) { + var field = iterator.next(); + if (field.getKey().equalsIgnoreCase(name)) { + return field.getValue(); + } + } + return null; + } + + private String faviconUrl(String html, String sourceUrl) { + if (html != null) { + String icon = first( + iconHref(html, "icon"), + iconHref(html, "shortcut icon"), + iconHref(html, "apple-touch-icon") + ); + if (!utils.isBlank(icon)) { + return absoluteUrl(sourceUrl, icon); + } + } + return fallbackFavicon(sourceUrl); + } + + private String fallbackFavicon(String sourceUrl) { + if (utils.isBlank(sourceUrl)) { + return null; + } + try { + URI uri = URI.create(sourceUrl); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + return uri.getScheme() + "://" + uri.getHost() + "/favicon.ico"; + } catch (Exception e) { + return null; + } + } + + private String iconHref(String html, String relValue) { + Matcher matcher = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE).matcher(html); + while (matcher.find()) { + String tag = matcher.group(); + String rel = attr(tag, "rel"); + if (rel == null || !rel.toLowerCase().contains(relValue.toLowerCase())) { + continue; + } + String href = attr(tag, "href"); + if (!utils.isBlank(href)) { + return href; + } + } + return null; + } + + private String meta(String html, String key) { + Matcher matcher = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE).matcher(html); + while (matcher.find()) { + String tag = matcher.group(); + if (key.equalsIgnoreCase(attr(tag, "property")) || key.equalsIgnoreCase(attr(tag, "name"))) { + return attr(tag, "content"); + } + } + return null; + } + + private String attr(String tag, String name) { + Matcher matcher = Pattern.compile(name + "\\s*=\\s*[\"']([^\"']*)[\"']", Pattern.CASE_INSENSITIVE).matcher(tag); + return matcher.find() ? utils.cleanText(matcher.group(1)) : null; + } + + private String htmlTitle(String html) { + Matcher matcher = Pattern.compile("]*>(.*?)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(html); + return matcher.find() ? utils.cleanText(matcher.group(1)) : null; + } + + private String headingTitle(String html) { + Matcher matcher = Pattern.compile("]*>(.*?)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(html); + while (matcher.find()) { + String title = utils.cleanTitle(stripTags(matcher.group(1))); + if (!utils.isBlank(title)) { + return title; + } + } + return null; + } + + private String firstImage(String html) { + Matcher matcher = Pattern.compile("]*>", Pattern.CASE_INSENSITIVE).matcher(html); + while (matcher.find()) { + String tag = matcher.group(); + String src = first(attr(tag, "src"), attr(tag, "data-src"), attr(tag, "data-original"), attr(tag, "data-lazy")); + if (!utils.isBlank(src) && !src.startsWith("data:")) { + return src; + } + } + return null; + } + + private String stripTags(String value) { + if (value == null) { + return null; + } + return utils.cleanText(value.replaceAll("(?is)|", " ").replaceAll("<[^>]+>", " ")); + } + + private String labeledValue(String text, String... labels) { + if (utils.isBlank(text)) { + return null; + } + + for (String label : labels) { + Matcher matcher = Pattern.compile(Pattern.quote(label) + "\\s*[::]?\\s*([^\\n|]{2,160})", Pattern.CASE_INSENSITIVE).matcher(text); + if (matcher.find()) { + return utils.cleanText(matcher.group(1)); + } + } + return null; + } + + private String strip(String html) { + return HtmlUtils.htmlUnescape(html + .replaceAll("(?is)|", " ") + .replaceAll("(?i)|

|||||", "\n") + .replaceAll("<[^>]+>", " ") + .replace('\u00A0', ' ') + .replaceAll("[\\t\\x0B\\f\\r ]+", " ") + .replaceAll("\\n+", "\n") + .trim()); + } + + private String absoluteUrl(String baseUrl, String value) { + if (utils.isBlank(value)) { + return null; + } + if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("data:image/")) { + return value; + } + try { + return URI.create(baseUrl).resolve(value).toString(); + } catch (Exception e) { + return value; + } + } + + private String cleanOrganizer(String value) { + String cleaned = utils.cleanText(value); + if (cleaned == null || cleaned.length() > 80) { + return null; + } + if (cleaned.matches(".*(문의|연락처|전화번호|영업시간|로그인|회원가입|공지|URL복사|약관|계좌).*")) { + return null; + } + return cleaned; + } + + private String compact(String value) { + if (value == null) { + return null; + } + String compact = value.replaceAll("\\s+", " ").trim(); + return compact.length() > 6_000 ? compact.substring(0, 6_000) : compact; + } + + private Integer firstPrice(Object... values) { + for (Object value : values) { + if (value == null) { + continue; + } + if (value instanceof Integer integer) { + return integer; + } + if (value instanceof Number number) { + return number.intValue(); + } + + String text = value.toString(); + if (utils.isBlank(text)) { + continue; + } + + Integer price = utils.extractPriceFromText(text, true); + if (price != null) { + return price; + } + if (text.contains("무료") || text.matches("(?is).*\\bfree\\b.*")) { + return 0; + } + price = utils.extractPriceFromText(text, false); + if (price != null) { + return price; + } + } + return null; + } + + @SafeVarargs + private final T first(T... values) { + for (T value : values) { + if (value == null) { + continue; + } + if (value instanceof String text) { + if (!text.isBlank()) { + return value; + } + } else { + return value; + } + } + return null; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/keyword/DiscoveryKeywordService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/keyword/DiscoveryKeywordService.java new file mode 100644 index 0000000..56ac29a --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/keyword/DiscoveryKeywordService.java @@ -0,0 +1,80 @@ +package com.jjikmeok.app.domain.activity.privateactivity.keyword; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +@Service +public class DiscoveryKeywordService { + + private static final Map> DEFAULT_KEYWORDS = buildDefaults(); + + public List categories() { + List categories = new ArrayList<>(); + Collections.addAll(categories, ActivityCategory.values()); + return categories; + } + + public List keywordsFor(ActivityCategory category, int limit) { + List keywords = DEFAULT_KEYWORDS.getOrDefault(category, List.of(category == null ? "활동" : category.getLabel())); + if (limit <= 0 || limit >= keywords.size()) { + return keywords; + } + return keywords.subList(0, limit); + } + + public Map> snapshot() { + return Collections.unmodifiableMap(DEFAULT_KEYWORDS); + } + + private static Map> buildDefaults() { + Map> keywords = new EnumMap<>(ActivityCategory.class); + keywords.put(ActivityCategory.SPORTS, expand( + new String[]{"운동모임", "등산모임", "러닝클럽", "마라톤", "축구", "야구", "테니스", "요가"}, + new String[]{" 모집", " 정기 모집", " 원데이 클래스", " 체험", " 참가자 모집", " 동호회", " 모임", " 클래스"})); + keywords.put(ActivityCategory.CULTURE, expand( + new String[]{"전시", "공연", "축제", "강연", "문화행사", "아트", "뮤지컬", "콘서트"}, + new String[]{" 신청", " 모집", " 참가", " 안내", " 추천", " 체험", " 예약", " 무료"})); + keywords.put(ActivityCategory.CRAFT, expand( + new String[]{"공예", "도자기", "만들기공예", "목공", "가죽공예", "비누공예", "캔들", "뜨개"}, + new String[]{" 클래스", " 원데이 클래스", " 체험", " 모집", " 워크숍", " 교육", " 과정", " 모임"})); + keywords.put(ActivityCategory.COOKING, expand( + new String[]{"요리", "베이킹", "쿠킹", "제빵", "디저트", "브런치", "커피", "채식"}, + new String[]{" 클래스", " 모집", " 원데이 클래스", " 체험", " 교육", " 워크숍", " 실습", " 과정"})); + keywords.put(ActivityCategory.PHOTO_VIDEO, expand( + new String[]{"사진", "영상", "촬영", "편집", "카메라", "브이로그", "쇼츠", "크리에이터"}, + new String[]{" 클래스", " 모집", " 워크숍", " 체험", " 교육", " 실무", " 기초반", " 세미나"})); + keywords.put(ActivityCategory.HUMANITIES, expand( + new String[]{"북토크", "독서모임", "인문학", "철학", "글쓰기", "문학", "에세이", "서평"}, + new String[]{" 모집", " 모임", " 클래스", " 강연", " 세미나", " 워크숍", " 신청", " 북클럽"})); + keywords.put(ActivityCategory.TRAVEL, expand( + new String[]{"여행", "탐방", "답사", "캠핑", "트레킹", "문화탐방", "도보", "투어"}, + new String[]{" 모집", " 체험", " 안내", " 신청", " 동행", " 코스", " 프로그램", " 클래스"})); + keywords.put(ActivityCategory.LANGUAGE, expand( + new String[]{"영어회화", "일본어", "중국어", "회화", "어학", "스피킹", "토익", "유학"}, + new String[]{" 클래스", " 모집", " 스터디", " 모임", " 원데이", " 교육", " 회화반", " 특강"})); + keywords.put(ActivityCategory.VOLUNTEER, expand( + new String[]{"봉사", "자원봉사", "환경", "아동", "노인", "유기견", "플로깅", "캠페인"}, + new String[]{" 모집", " 활동", " 프로그램", " 참여자 모집", " 봉사단", " 정기봉사", " 캠페인", " 행사"})); + keywords.put(ActivityCategory.CAREER, expand( + new String[]{"취업", "채용", "커리어", "직무", "실무", "포트폴리오", "면접", "멘토링"}, + new String[]{" 모집", " 컨설팅", " 특강", " 코칭", " 스터디", " 교육", " 세미나", " 프로그램"})); + return keywords; + } + + private static List expand(String[] stems, String[] suffixes) { + LinkedHashSet values = new LinkedHashSet<>(); + for (String stem : stems) { + for (String suffix : suffixes) { + values.add(stem + suffix); + } + } + return List.copyOf(values); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/publish/DiscoveryPublishService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/publish/DiscoveryPublishService.java new file mode 100644 index 0000000..712fcd9 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/publish/DiscoveryPublishService.java @@ -0,0 +1,169 @@ +package com.jjikmeok.app.domain.activity.privateactivity.publish; + +import com.jjikmeok.app.domain.activity.dto.request.ActivityRequest; +import com.jjikmeok.app.domain.activity.dto.response.ActivityDetailResponse; +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.privateactivity.deduplication.DiscoveryDeduplicationService; +import com.jjikmeok.app.domain.activity.privateactivity.dto.response.DiscoverySheetRowDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySheetStatus; +import com.jjikmeok.app.domain.activity.privateactivity.sheets.GoogleSheetsService; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivityRegionResolver; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import com.jjikmeok.app.domain.activity.service.ActivityService; +import com.jjikmeok.app.domain.region.entity.Region; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscoveryPublishService { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + + private final GoogleSheetsService googleSheetsService; + private final DiscoveryDeduplicationService deduplicationService; + private final ActivityService activityService; + private final ActivityRegionResolver activityRegionResolver; + private final ActivitySyncUtils utils; + + @Value("${app.activity-sync.default-region-id:1}") + private Long defaultRegionId; + + public int publishReadyRows() { + int publishedCount = 0; + List readyRows = googleSheetsService.findReadyRows(); + for (DiscoverySheetRowDto row : readyRows) { + if (row == null) { + continue; + } + + if (process(row)) { + publishedCount++; + } + } + return publishedCount; + } + + private boolean process(DiscoverySheetRowDto row) { + DiscoverySheetRowDto reviewing = row.withStatus(DiscoverySheetStatus.REVIEWING, null); + try { + googleSheetsService.updateRow(reviewing); + } catch (Exception e) { + log.warn("[발행] 시트 상태를 검토 중으로 변경하지 못했습니다. row={}, reason={}", row.rowNumber(), e.getMessage(), e); + return false; + } + + try { + String duplicateReason = deduplicationService.findDuplicateReason( + reviewing.sourceUrl(), + reviewing.title(), + reviewing.organizer() + ).orElse(null); + + if (duplicateReason != null) { + googleSheetsService.updateRow(reviewing.withStatus(DiscoverySheetStatus.DUPLICATE, null)); + log.info("[발행] 중복으로 판단되어 발행하지 않았습니다. row={}, reason={}", row.rowNumber(), duplicateReason); + return false; + } + + Region region = activityRegionResolver.resolve(reviewing.title(), reviewing.address(), defaultRegionId); + ActivityRequest request = toActivityRequest(reviewing, region.getId()); + ActivityDetailResponse response = activityService.createActivity(request); + if (response == null) { + throw new IllegalStateException("활동 생성 응답이 비어 있습니다."); + } + + googleSheetsService.updateRow(reviewing.withStatus( + DiscoverySheetStatus.PUBLISHED, + LocalDateTime.now(SEOUL) + )); + log.info("[발행] 활동 발행을 완료했습니다. row={}, activityId={}", row.rowNumber(), response.id()); + return true; + } catch (Exception e) { + try { + googleSheetsService.updateRow(reviewing.withStatus(DiscoverySheetStatus.ERROR, null)); + } catch (Exception updateError) { + log.error("[발행] 오류 상태 갱신에 실패했습니다. row={}, reason={}", row.rowNumber(), updateError.getMessage(), updateError); + } + log.warn("[발행] 활동 발행에 실패했습니다. row={}, reason={}", row.rowNumber(), e.getMessage(), e); + return false; + } + } + + private ActivityRequest toActivityRequest(DiscoverySheetRowDto row, Long regionId) { + String title = firstText(row.title(), row.keyword(), row.searchSnippet(), row.sourceUrl()); + String description = firstText(row.description(), title, "활동 설명은 원문에서 확인하세요."); + String sourceUrl = firstText(row.sourceUrl()); + String organizer = row.organizer(); + String contactInfo = row.contactInfo(); + String target = row.target(); + String address = row.address(); + Integer price = row.price() == null ? 0 : row.price(); + ActivityCategory category = row.category() == null ? ActivityCategory.CULTURE : row.category(); + ActivityType activityType = row.activityType() == null ? ActivityType.EVENT : row.activityType(); + LocalDateTime recruitEndAt = row.recruitEndAt() != null ? row.recruitEndAt() + : (row.endAt() != null ? row.endAt() : LocalDateTime.now(SEOUL).plusMonths(1)); + + return new ActivityRequest( + regionId, + title, + description, + row.thumbnailUrl(), + sourceUrl, + address, + organizer, + contactInfo, + target, + row.startAt(), + row.endAt(), + row.recruitStartAt(), + recruitEndAt, + price, + activityType, + category, + SourceType.DISCOVERY, + createDiscoveryExternalId(sourceUrl), + ApprovalStatus.APPROVED, + true + ); + } + + private String firstText(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + return null; + } + + private String createDiscoveryExternalId(String sourceUrl) { + return hashExternalIdSeed(sourceUrl); + } + + private String hashExternalIdSeed(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 12 && i < hash.length; i++) { + builder.append(String.format("%02x", hash[i])); + } + return builder.toString(); + } catch (Exception e) { + return Integer.toHexString((value == null ? "" : value).hashCode()); + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryPublishScheduler.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryPublishScheduler.java new file mode 100644 index 0000000..120b978 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryPublishScheduler.java @@ -0,0 +1,24 @@ +package com.jjikmeok.app.domain.activity.privateactivity.scheduler; + +import com.jjikmeok.app.domain.activity.privateactivity.publish.DiscoveryPublishService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.discovery.scheduler.enabled", havingValue = "true", matchIfMissing = false) +public class DiscoveryPublishScheduler { + + private final DiscoveryPublishService discoveryPublishService; + + @Scheduled(fixedDelay = 300000) + public void runDiscoveryPublish() { + log.info("[발행] 발행 스케줄 실행을 시작합니다."); + int processed = discoveryPublishService.publishReadyRows(); + log.info("[발행] 발행 스케줄 실행을 완료했습니다. 처리된 행 수={}", processed); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryScheduler.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryScheduler.java new file mode 100644 index 0000000..be2f509 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/scheduler/DiscoveryScheduler.java @@ -0,0 +1,36 @@ +package com.jjikmeok.app.domain.activity.privateactivity.scheduler; + +import com.jjikmeok.app.domain.activity.privateactivity.collector.DiscoveryCollectorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "app.discovery.scheduler.enabled", havingValue = "true", matchIfMissing = false) +public class DiscoveryScheduler { + + private final DiscoveryCollectorService discoveryCollectorService; + + @Value("${app.discovery.scheduler.keywords-per-run:${app.discovery.scheduler.keyword-limit:10}}") + private int keywordLimit; + + @Value("${app.discovery.search.results-per-keyword:${app.discovery.search.result-limit:10}}") + private int resultLimit; + + @Scheduled(cron = "${app.discovery.scheduler.cron:0 0 3 ? * MON,FRI}", zone = "Asia/Seoul") + public void runDiscoveryPipeline() { + log.info("[Discovery] 수집 스케줄 실행을 시작합니다. keywordLimit={}, resultLimit={}", keywordLimit, resultLimit); + int rows = discoveryCollectorService.runAll(keywordLimit, resultLimit).size(); + if (rows < keywordLimit && keywordLimit < 20) { + int expandedKeywordLimit = 20; + log.info("[Discovery] 후보 수가 부족하여 키워드 범위를 확장합니다. keywordLimit={}, nextKeywordLimit={}", rows, expandedKeywordLimit); + rows += discoveryCollectorService.runAll(expandedKeywordLimit, resultLimit).size(); + } + log.info("[Discovery] 수집 스케줄 실행을 완료했습니다. 저장된 후보 수={}", rows); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SearchService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SearchService.java new file mode 100644 index 0000000..8774798 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SearchService.java @@ -0,0 +1,10 @@ +package com.jjikmeok.app.domain.activity.privateactivity.search; + +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; + +import java.util.List; + +public interface SearchService { + + List search(String keyword, int limit); +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SerperSearchServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SerperSearchServiceImpl.java new file mode 100644 index 0000000..5dcb2f6 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/search/SerperSearchServiceImpl.java @@ -0,0 +1,114 @@ +package com.jjikmeok.app.domain.activity.privateactivity.search; + +import com.fasterxml.jackson.databind.JsonNode; +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.privateactivity.support.DiscoveryUrlNormalizer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class SerperSearchServiceImpl implements SearchService { + + private final RestClient restClient = RestClient.create(); + private final DiscoveryUrlNormalizer urlNormalizer; + + @Value("${app.discovery.search.provider:serper}") + private String provider; + + @Value("${app.discovery.search.results-per-keyword:${app.discovery.search.result-limit:10}}") + private int defaultLimit; + + @Value("${app.discovery.search.serper.base-url:https://google.serper.dev/search}") + private String baseUrl; + + @Value("${app.discovery.search.serper.api-key:}") + private String apiKey; + + @Value("${app.discovery.search.serper.gl:kr}") + private String gl; + + @Value("${app.discovery.search.serper.hl:ko}") + private String hl; + + public SerperSearchServiceImpl(DiscoveryUrlNormalizer urlNormalizer) { + this.urlNormalizer = urlNormalizer; + } + + @Override + public List search(String keyword, int limit) { + if (keyword == null || keyword.isBlank()) { + return List.of(); + } + if (!"serper".equalsIgnoreCase(provider)) { + return List.of(); + } + if (baseUrl == null || baseUrl.isBlank()) { + log.warn("[Discovery] Serper 기본 URL이 없습니다. 검색어={}", keyword); + return List.of(); + } + if (apiKey == null || apiKey.isBlank()) { + log.warn("[Discovery] Serper API 키가 없습니다. 검색어={}", keyword); + return List.of(); + } + + int effectiveLimit = Math.max(1, limit > 0 ? limit : defaultLimit); + + Map body = new LinkedHashMap<>(); + body.put("q", keyword); + body.put("num", effectiveLimit); + if (gl != null && !gl.isBlank()) { + body.put("gl", gl); + } + if (hl != null && !hl.isBlank()) { + body.put("hl", hl); + } + + try { + JsonNode root = restClient.post() + .uri(baseUrl) + .header("X-API-KEY", apiKey) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(JsonNode.class); + + if (root == null || !root.has("organic") || !root.get("organic").isArray()) { + return List.of(); + } + + List results = new ArrayList<>(); + for (JsonNode item : root.get("organic")) { + String title = text(item, "title"); + String url = urlNormalizer.normalize(text(item, "link")); + String snippet = text(item, "snippet"); + Integer position = item.hasNonNull("position") ? item.get("position").asInt() : null; + if (title == null || url == null) { + continue; + } + results.add(new SearchResultDto(keyword, title, url, snippet, position, "serper", null)); + } + return results; + } catch (Exception e) { + log.warn("[Discovery] Serper 검색에 실패했습니다. 검색어={}, 사유={}", keyword, e.getMessage()); + return List.of(); + } + } + + private String text(JsonNode node, String field) { + if (node == null || !node.has(field) || node.get(field).isNull()) { + return null; + } + String value = node.get(field).asText(); + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityService.java new file mode 100644 index 0000000..e3e7e08 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityService.java @@ -0,0 +1,168 @@ +package com.jjikmeok.app.domain.activity.privateactivity.service; + +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySourceChannel; +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.util.List; +import java.util.Locale; + +@Service +@Slf4j +public class DiscoveryUrlQualityService { + + private static final List ADS = List.of("광고", "스폰서", "후원", "프로모션", "배너"); + private static final List NEWS = List.of("뉴스", "기사", "보도", "속보"); + private static final List COMMUNITY = List.of("커뮤니티", "게시판", "카페", "모임"); + private static final List SEARCH = List.of("검색결과", "검색어", "검색", "찾으시는"); + private static final List ENDED = List.of("마감", "종료", "신청 종료", "모집 종료"); + private static final List LOGIN = List.of("로그인", "회원 전용", "sign in", "login required", "unauthorized", "private"); + private static final List SIGNALS = List.of("모집", "신청", "접수", "참가", "예약"); + private static final List ACTIVITY = List.of("활동", "프로그램", "강연", "체험", "행사", "클래스", "투어", "전시", "봉사"); + private static final List OPERATING = List.of("운영", "진행", "주최", "주관"); + private static final List TARGET = List.of("대상", "누구나", "청소년", "성인", "대학생", "어린이", "가족"); + private static final List UNSUPPORTED_HOSTS = List.of("facebook.com", "x.com", "twitter.com", "onoffmix.com", "event-us.kr", "frip.co.kr", "munto.kr"); + private static final List URL_ONLY_HOSTS = List.of("instagram.com", "band.us", "cafe.naver.com"); + private static final List METADATA_HOSTS = List.of("blog.naver.com", "brunch.co.kr", "tistory.com", "notion.site"); + private static final List FULL_CONTENT_HOST_SUFFIXES = List.of("go.kr", "or.kr", "ac.kr", "re.kr"); + private static final List FULL_CONTENT_TEXT_HINTS = List.of("공공기관", "지자체", "교육청", "복지관"); + + public Assessment evaluate(SearchResultDto searchResult) { + String url = searchResult == null ? null : searchResult.url(); + String title = lower(searchResult == null ? null : searchResult.title()); + String snippet = lower(searchResult == null ? null : searchResult.snippet()); + String host = host(url); + String text = join(title, snippet, lower(url)); + + if (matchesAnyHost(host, UNSUPPORTED_HOSTS)) { + return new Assessment(ExtractionMode.URL_ONLY, 0, true, host, DiscoverySourceChannel.WEBSITE); + } + + if (containsAny(text, ADS) || containsAny(text, NEWS) || containsAny(text, COMMUNITY) || containsAny(text, SEARCH) || containsAny(text, ENDED)) { + return new Assessment(ExtractionMode.URL_ONLY, 0, true, host, classifySourceChannel(host)); + } + + ExtractionMode mode = resolveMode(host, text); + double score = switch (mode) { + case FULL_CONTENT -> 20; + case METADATA_ONLY -> 10; + case URL_ONLY -> 0; + }; + + if (containsAny(text, SIGNALS)) score += 6; + if (containsAny(text, ACTIVITY)) score += 6; + if (containsAny(text, OPERATING)) score += 4; + if (containsAny(text, TARGET)) score += 4; + if (containsAny(text, LOGIN)) { + mode = ExtractionMode.URL_ONLY; + score -= 20; + } + + return new Assessment(mode, score, false, host, classifySourceChannel(host)); + } + + private ExtractionMode resolveMode(String host, String text) { + if (containsAny(text, LOGIN)) { + return ExtractionMode.URL_ONLY; + } + if (matchesAnyHost(host, URL_ONLY_HOSTS)) { + return ExtractionMode.URL_ONLY; + } + if (matchesAnyHost(host, METADATA_HOSTS)) { + return ExtractionMode.METADATA_ONLY; + } + if (matchesAnyHost(host, FULL_CONTENT_HOST_SUFFIXES) || containsAny(text, FULL_CONTENT_TEXT_HINTS)) { + return ExtractionMode.FULL_CONTENT; + } + return ExtractionMode.METADATA_ONLY; + } + + private boolean containsAny(String text, List keywords) { + if (text == null || text.isBlank()) { + return false; + } + for (String keyword : keywords) { + if (text.contains(lower(keyword))) { + return true; + } + } + return false; + } + + private boolean matchesAnyHost(String host, List domains) { + if (host == null || host.isBlank()) { + return false; + } + for (String domain : domains) { + if (matchesHost(host, domain)) { + return true; + } + } + return false; + } + + private boolean matchesHost(String host, String domain) { + if (host == null || host.isBlank() || domain == null || domain.isBlank()) { + return false; + } + String normalizedHost = lower(host); + String normalizedDomain = lower(domain); + return normalizedHost.equals(normalizedDomain) || normalizedHost.endsWith("." + normalizedDomain); + } + + private String join(String... values) { + StringBuilder builder = new StringBuilder(); + for (String value : values) { + if (value == null || value.isBlank()) { + continue; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(value); + } + return builder.toString(); + } + + private String lower(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } + + private String host(String url) { + if (url == null || url.isBlank()) { + return ""; + } + try { + URI uri = URI.create(url); + return lower(uri.getHost()); + } catch (Exception e) { + return ""; + } + } + + private DiscoverySourceChannel classifySourceChannel(String host) { + if (host == null || host.isBlank()) { + return DiscoverySourceChannel.WEBSITE; + } + if (matchesHost(host, "instagram.com")) return DiscoverySourceChannel.INSTAGRAM; + if (matchesHost(host, "blog.naver.com")) return DiscoverySourceChannel.NAVER_BLOG; + if (matchesHost(host, "cafe.naver.com")) return DiscoverySourceChannel.NAVER_CAFE; + if (matchesHost(host, "band.us")) return DiscoverySourceChannel.BAND; + if (matchesHost(host, "brunch.co.kr")) return DiscoverySourceChannel.BRUNCH; + if (matchesHost(host, "tistory.com")) return DiscoverySourceChannel.TISTORY; + if (matchesHost(host, "notion.site") || matchesHost(host, "notion.so")) return DiscoverySourceChannel.NOTION; + return DiscoverySourceChannel.WEBSITE; + } + + public record Assessment( + ExtractionMode extractionMode, + double confidenceScore, + boolean excluded, + String platform, + DiscoverySourceChannel sourceChannel + ) { + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyService.java new file mode 100644 index 0000000..4b0fcc6 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyService.java @@ -0,0 +1,93 @@ +package com.jjikmeok.app.domain.activity.privateactivity.service; + +import com.jjikmeok.app.domain.activity.privateactivity.enums.RobotsPolicy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +public class RobotsPolicyService { + + private final RestClient restClient = RestClient.create(); + + public RobotsPolicy evaluate(URI sourceUri) { + if (sourceUri == null || sourceUri.getHost() == null || sourceUri.getHost().isBlank()) { + return RobotsPolicy.UNKNOWN; + } + + URI robotsUri = buildRobotsUri(sourceUri); + try { + String robotsText = restClient.get().uri(robotsUri).retrieve().body(String.class); + if (robotsText == null || robotsText.isBlank()) { + return RobotsPolicy.ALLOWED; + } + return parseRobots(robotsText, sourceUri.getPath()); + } catch (Exception e) { + log.debug("[Discovery] robots.txt 확인에 실패했습니다. url={}", sourceUri, e); + return RobotsPolicy.UNKNOWN; + } + } + + private RobotsPolicy parseRobots(String robotsText, String path) { + String normalizedPath = path == null || path.isBlank() ? "/" : path; + boolean wildcard = false; + boolean active = false; + List rules = new ArrayList<>(); + + for (String line : robotsText.split("\\R")) { + String trimmed = stripInlineComment(line); + if (trimmed.isBlank()) { + active = false; + wildcard = false; + continue; + } + + int colon = trimmed.indexOf(':'); + if (colon < 0) { + continue; + } + + String key = trimmed.substring(0, colon).trim().toLowerCase(); + String value = trimmed.substring(colon + 1).trim(); + if ("user-agent".equals(key)) { + wildcard = "*".equals(value); + active = wildcard; + continue; + } + + if (active && "disallow".equals(key)) { + if (value.isBlank()) { + continue; + } + rules.add(value); + } + } + + for (String rule : rules) { + if ("/".equals(rule) || normalizedPath.startsWith(rule)) { + return RobotsPolicy.DISALLOWED; + } + } + return RobotsPolicy.ALLOWED; + } + + private String stripInlineComment(String line) { + if (line == null) { + return ""; + } + return line.split("#", 2)[0].trim(); + } + + private URI buildRobotsUri(URI sourceUri) { + return URI.create("%s://%s%s/robots.txt".formatted( + sourceUri.getScheme(), + sourceUri.getHost(), + sourceUri.getPort() >= 0 ? ":" + sourceUri.getPort() : "" + )); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/sheets/GoogleSheetsService.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/sheets/GoogleSheetsService.java new file mode 100644 index 0000000..5380e68 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/sheets/GoogleSheetsService.java @@ -0,0 +1,458 @@ +package com.jjikmeok.app.domain.activity.privateactivity.sheets; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.auth.oauth2.GoogleCredentials; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.ai.dto.DiscoveryAnalysisDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.response.DiscoverySheetRowDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySheetStatus; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import jakarta.annotation.PostConstruct; +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +@Service +@Slf4j +public class GoogleSheetsService { + + private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); + private static final List SCOPES = List.of("https://www.googleapis.com/auth/spreadsheets"); + + private final RestClient restClient = RestClient.create(); + private final List memoryRows = new CopyOnWriteArrayList<>(); + private final AtomicInteger memorySequence = new AtomicInteger(1); + + @Value("${app.discovery.sheets.enabled:false}") + private boolean enabled; + + @Value("${app.discovery.sheets.spreadsheet-id:}") + private String spreadsheetId; + + @Value("${app.discovery.sheets.sheet-name:Discovery}") + private String sheetName; + + @Value("${app.discovery.sheets.credentials-path:}") + private String credentialsPath; + + @Value("${app.discovery.sheets.credentials-json:}") + private String credentialsJson; + + private volatile GoogleCredentials googleCredentials; + + @PostConstruct + void init() { + if (enabled && hasApiConfiguration()) { + googleCredentials = loadCredentials(); + if (googleCredentials != null) { + ensureHeaders(); + } + if (googleCredentials == null) { + log.warn("[발견] Google Sheets 자격 증명을 불러오지 못해 메모리 모드로 동작합니다."); + } + } + } + + public DiscoverySheetRowDto append(DiscoveryAnalysisDto analysis) { + if (enabled && isApiReady()) { + return appendToSheet(analysis); + } + return appendToMemory(analysis); + } + + public DiscoverySheetRowDto append(DiscoveryCandidateDto candidate) { + if (enabled && isApiReady()) { + return appendCandidateToSheet(candidate); + } + return appendCandidateToMemory(candidate); + } + + public DiscoverySheetRowDto upsertPublicActivity(NormalizedActivity activity) { + DiscoverySheetRowDto existing = findPublicRow(activity); + if (existing != null) { + DiscoverySheetRowDto updated = copyPublicRow(existing, activity); + updateRow(updated); + return updated; + } + + if (enabled && isApiReady()) { + return appendPublicActivityToSheet(activity); + } + return appendPublicActivityToMemory(activity); + } + + public List snapshot() { + if (enabled && isApiReady()) { + List rows = readRowsFromSheet("A2:AC"); + if (!rows.isEmpty()) { + return rows; + } + } + return List.copyOf(memoryRows); + } + + public List findReadyRows() { + if (enabled && isApiReady()) { + return readReadyRowsFromSheet(); + } + return memoryRows.stream() + .filter(row -> row.status() == DiscoverySheetStatus.READY) + .toList(); + } + + public void updateRow(DiscoverySheetRowDto row) { + if (row == null) { + return; + } + + if (enabled && isApiReady()) { + writeRowToSheet(row); + } + replaceMemoryRow(row); + } + + public void clear() { + if (enabled && isApiReady()) { + clearSheetRows(); + } + memoryRows.clear(); + memorySequence.set(1); + } + + private DiscoverySheetRowDto appendToMemory(DiscoveryAnalysisDto analysis) { + int rowNumber = memorySequence.incrementAndGet(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.from(analysis, rowNumber, LocalDateTime.now(SEOUL)); + memoryRows.add(row); + log.info("[발견] 임시 저장했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private DiscoverySheetRowDto appendCandidateToMemory(DiscoveryCandidateDto candidate) { + int rowNumber = memorySequence.incrementAndGet(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.fromCandidate(candidate, rowNumber, LocalDateTime.now(SEOUL)); + memoryRows.add(row); + log.info("[발견] 추출 결과를 먼저 저장했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private DiscoverySheetRowDto appendToSheet(DiscoveryAnalysisDto analysis) { + int rowNumber = nextRowNumber(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.from(analysis, rowNumber, LocalDateTime.now(SEOUL)); + putRowValues(rowRange(rowNumber), row.toSheetRow()); + replaceMemoryRow(row); + log.info("[발견] 시트에 저장했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private DiscoverySheetRowDto appendCandidateToSheet(DiscoveryCandidateDto candidate) { + int rowNumber = nextRowNumber(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.fromCandidate(candidate, rowNumber, LocalDateTime.now(SEOUL)); + putRowValues(rowRange(rowNumber), row.toSheetRow()); + replaceMemoryRow(row); + log.info("[발견] 추출 결과를 시트에 먼저 저장했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private DiscoverySheetRowDto appendPublicActivityToMemory(NormalizedActivity activity) { + int rowNumber = memorySequence.incrementAndGet(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.fromPublicActivity(activity, rowNumber, LocalDateTime.now(SEOUL)); + memoryRows.add(row); + log.info("[시트] 공공 활동을 메모리에 캐시했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private DiscoverySheetRowDto appendPublicActivityToSheet(NormalizedActivity activity) { + int rowNumber = nextRowNumber(); + DiscoverySheetRowDto row = DiscoverySheetRowDto.fromPublicActivity(activity, rowNumber, LocalDateTime.now(SEOUL)); + putRowValues(rowRange(rowNumber), row.toSheetRow()); + replaceMemoryRow(row); + log.info("[시트] 공공 활동을 시트에 추가했습니다. 행 번호={}, 제목={}", row.rowNumber(), row.title()); + return row; + } + + private List readRowsFromSheet(String range) { + JsonNode root = getSheetValues(range); + JsonNode values = root == null ? null : root.path("values"); + if (values == null || !values.isArray()) { + return List.of(); + } + + List rows = new ArrayList<>(); + int rowNumber = 2; + for (JsonNode valueRow : values) { + rows.add(DiscoverySheetRowDto.fromSheetRow(rowNumber++, asValueList(valueRow))); + } + return rows; + } + + private List readReadyRowsFromSheet() { + JsonNode root = getSheetValues("B2:B"); + JsonNode values = root == null ? null : root.path("values"); + if (values == null || !values.isArray()) { + return List.of(); + } + + List rows = new ArrayList<>(); + int rowNumber = 2; + for (JsonNode statusRow : values) { + String status = text(statusRow, 0); + if (DiscoverySheetStatus.READY.name().equalsIgnoreCase(status)) { + DiscoverySheetRowDto row = readRowFromSheet(rowNumber); + if (row != null) { + rows.add(row); + } + } + rowNumber++; + } + return rows; + } + + private DiscoverySheetRowDto readRowFromSheet(int rowNumber) { + JsonNode root = getSheetValues(rowRange(rowNumber)); + JsonNode values = root == null ? null : root.path("values"); + if (values != null && values.isArray() && !values.isEmpty()) { + return DiscoverySheetRowDto.fromSheetRow(rowNumber, asValueList(values.get(0))); + } + return null; + } + + private void writeRowToSheet(DiscoverySheetRowDto row) { + putRowValues(rowRange(row.rowNumber()), row.toSheetRow()); + } + + private void clearSheetRows() { + try { + restClient.post() + .uri(builder -> builder + .scheme("https") + .host("sheets.googleapis.com") + .path("/v4/spreadsheets/{spreadsheetId}/values/{range}:clear") + .build(spreadsheetId, sheetName + "!A2:AC")) + .headers(headers -> headers.setBearerAuth(accessToken())) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + log.warn("[발견] Google Sheets 초기화에 실패했습니다. reason={}", e.getMessage()); + } + } + + private JsonNode getSheetValues(String range) { + try { + return restClient.get() + .uri(builder -> builder + .scheme("https") + .host("sheets.googleapis.com") + .path("/v4/spreadsheets/{spreadsheetId}/values/{range}") + .build(spreadsheetId, sheetName + "!" + range)) + .headers(headers -> headers.setBearerAuth(accessToken())) + .retrieve() + .body(JsonNode.class); + } catch (Exception e) { + log.warn("[발견] Google Sheets 조회에 실패했습니다. range={}, reason={}", range, e.getMessage()); + return null; + } + } + + private void ensureHeaders() { + try { + JsonNode root = getSheetValues("A1:AC1"); + JsonNode values = root == null ? null : root.path("values"); + if (values != null && values.isArray() && !values.isEmpty()) { + List current = asValueList(values.get(0)); + if (current.size() >= DiscoverySheetRowDto.sheetHeaders().length) { + return; + } + } + putRowValues("A1:AC1", new ArrayList<>(Arrays.asList(DiscoverySheetRowDto.sheetHeaders()))); + } catch (Exception e) { + log.warn("[시트] 헤더 검증에 실패했습니다. reason={}", e.getMessage()); + } + } + + private void putRowValues(String range, List values) { + try { + restClient.put() + .uri(builder -> builder + .scheme("https") + .host("sheets.googleapis.com") + .path("/v4/spreadsheets/{spreadsheetId}/values/{range}") + .queryParam("valueInputOption", "RAW") + .build(spreadsheetId, sheetName + "!" + range)) + .headers(headers -> headers.setBearerAuth(accessToken())) + .body(java.util.Map.of("values", java.util.List.of(values))) + .retrieve() + .toBodilessEntity(); + } catch (Exception e) { + throw new IllegalStateException("Google Sheets 업데이트에 실패했습니다: " + e.getMessage(), e); + } + } + + private int nextRowNumber() { + return snapshot().stream() + .mapToInt(DiscoverySheetRowDto::rowNumber) + .max() + .orElse(1) + 1; + } + + private void replaceMemoryRow(DiscoverySheetRowDto row) { + for (int i = 0; i < memoryRows.size(); i++) { + if (memoryRows.get(i).rowNumber() == row.rowNumber()) { + memoryRows.set(i, row); + return; + } + } + memoryRows.add(row); + } + + private DiscoverySheetRowDto findPublicRow(NormalizedActivity activity) { + String sourceUrl = blankToNull(activity.sourceUrl()); + String sourceName = activity.sourceType() == null ? null : activity.sourceType().name(); + + for (DiscoverySheetRowDto row : snapshot()) { + if (row == null || !isPublicSourceName(row.sourceName())) { + continue; + } + if (sourceUrl != null && sourceUrl.equals(blankToNull(row.sourceUrl()))) { + return row; + } + if (sourceUrl == null + && equalsNullable(sourceName, row.sourceName()) + && equalsNullable(blankToNull(activity.title()), blankToNull(row.title())) + && equalsNullable(activity.startAt(), row.startAt())) { + return row; + } + } + return null; + } + + private DiscoverySheetRowDto copyPublicRow(DiscoverySheetRowDto existing, NormalizedActivity activity) { + return new DiscoverySheetRowDto( + existing.rowNumber(), + DiscoverySheetStatus.PUBLISHED, + existing.createdAt(), + existing.publishedAt(), + null, + activity.sourceType() == null ? existing.sourceName() : activity.sourceType().name(), + activity.title(), + activity.sourceUrl(), + activity.thumbnailUrl(), + activity.activityType(), + activity.category(), + activity.startAt(), + activity.endAt(), + activity.recruitStartAt(), + activity.recruitEndAt(), + activity.target(), + activity.price(), + activity.description(), + activity.contactInfo(), + activity.organizer(), + activity.address(), + existing.moodTag1(), + existing.moodTag2(), + existing.intensity(), + existing.purpose(), + existing.duration(), + existing.groupSize(), + existing.confidenceScore(), + existing.searchSnippet() + ); + } + + private boolean hasApiConfiguration() { + return spreadsheetId != null && !spreadsheetId.isBlank(); + } + + private boolean isApiReady() { + return hasApiConfiguration() && googleCredentials != null; + } + + private GoogleCredentials loadCredentials() { + try { + if (credentialsJson != null && !credentialsJson.isBlank()) { + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(credentialsJson.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { + return GoogleCredentials.fromStream(inputStream).createScoped(SCOPES); + } + } + + if (credentialsPath != null && !credentialsPath.isBlank()) { + Path path = Path.of(credentialsPath); + if (Files.exists(path)) { + try (FileInputStream inputStream = new FileInputStream(path.toFile())) { + return GoogleCredentials.fromStream(inputStream).createScoped(SCOPES); + } + } + } + + return GoogleCredentials.getApplicationDefault().createScoped(SCOPES); + } catch (Exception e) { + log.warn("[발견] Google Sheets 자격 증명 로드에 실패했습니다. reason={}", e.getMessage()); + return null; + } + } + + private String accessToken() { + if (googleCredentials == null) { + throw new IllegalStateException("Google Sheets 자격 증명이 없습니다."); + } + try { + googleCredentials.refreshIfExpired(); + if (googleCredentials.getAccessToken() == null) { + throw new IllegalStateException("Google Sheets 액세스 토큰이 없습니다."); + } + return googleCredentials.getAccessToken().getTokenValue(); + } catch (Exception e) { + throw new IllegalStateException("Google Sheets 인증에 실패했습니다: " + e.getMessage(), e); + } + } + + private String rowRange(int rowNumber) { + return "A" + rowNumber + ":AC" + rowNumber; + } + + private List asValueList(JsonNode rowNode) { + List values = new ArrayList<>(); + if (rowNode == null || !rowNode.isArray()) { + return values; + } + + rowNode.forEach(cell -> values.add(cell == null || cell.isNull() ? null : cell.asText())); + return values; + } + + private String text(JsonNode rowNode, int index) { + if (rowNode == null || !rowNode.isArray() || index < 0 || index >= rowNode.size()) { + return null; + } + JsonNode cell = rowNode.get(index); + return cell == null || cell.isNull() ? null : cell.asText(); + } + + private boolean equalsNullable(Object left, Object right) { + return left == null ? right == null : left.equals(right); + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private boolean isPublicSourceName(String sourceName) { + return "KOPIS".equals(sourceName) + || "EXHIBITION".equals(sourceName) + || "SEOUL_CULTURE".equals(sourceName) + || "SEOUL_RESERVATION".equals(sourceName); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizer.java b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizer.java new file mode 100644 index 0000000..9d0164d --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizer.java @@ -0,0 +1,81 @@ +package com.jjikmeok.app.domain.activity.privateactivity.support; + +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class DiscoveryUrlNormalizer { + + private static final Set TRACKING_PARAMS = new HashSet<>(Set.of( + "fbclid", "gclid", "igshid", "igsh", "mc_cid", "mc_eid", "si" + )); + + public String normalize(String value) { + if (value == null || value.isBlank()) { + return null; + } + + try { + URI uri = URI.create(value.trim()); + if (uri.getHost() == null || uri.getHost().isBlank()) { + return value.trim(); + } + + String scheme = uri.getScheme() == null ? "https" : uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost().toLowerCase(Locale.ROOT); + int port = normalizePort(scheme, uri.getPort()); + String path = uri.getRawPath(); + String query = normalizeQuery(uri.getRawQuery()); + + URI normalized = new URI( + scheme, + null, + host, + port, + path == null || path.isBlank() ? null : path, + query, + null + ); + return normalized.toString(); + } catch (Exception e) { + return value.trim(); + } + } + + private int normalizePort(String scheme, int port) { + if (port < 0) { + return -1; + } + if ("http".equals(scheme) && port == 80) { + return -1; + } + if ("https".equals(scheme) && port == 443) { + return -1; + } + return port; + } + + private String normalizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + + String normalized = Arrays.stream(query.split("&")) + .map(String::trim) + .filter(part -> !part.isBlank()) + .filter(part -> { + String key = part.split("=", 2)[0].toLowerCase(Locale.ROOT); + return !key.startsWith("utm_") && !TRACKING_PARAMS.contains(key); + }) + .sorted() + .collect(Collectors.joining("&")); + + return normalized.isBlank() ? null : normalized; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/sync/dto/ActivitySyncResponse.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/ActivitySyncResponse.java similarity index 78% rename from src/main/java/com/jjikmeok/app/domain/sync/dto/ActivitySyncResponse.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/ActivitySyncResponse.java index a897fc4..2e6a8d4 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/dto/ActivitySyncResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/ActivitySyncResponse.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.dto; +package com.jjikmeok.app.domain.activity.publicactivity.dto; import com.jjikmeok.app.domain.activity.enums.SourceType; diff --git a/src/main/java/com/jjikmeok/app/domain/sync/dto/NormalizedActivity.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/NormalizedActivity.java similarity index 72% rename from src/main/java/com/jjikmeok/app/domain/sync/dto/NormalizedActivity.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/NormalizedActivity.java index 3349989..24d9ad5 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/dto/NormalizedActivity.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/dto/NormalizedActivity.java @@ -1,9 +1,10 @@ -package com.jjikmeok.app.domain.sync.dto; +package com.jjikmeok.app.domain.activity.publicactivity.dto; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; import com.jjikmeok.app.domain.activity.enums.SourceType; + import java.time.LocalDateTime; public record NormalizedActivity( @@ -27,8 +28,9 @@ public record NormalizedActivity( ApprovalStatus approvalStatus, Boolean active ) { - // 🌟 1순위 데이터 병합 및 2순위 누락 데이터 선택적 복원 메서드 public NormalizedActivity copyWithFallbackFields( + String aiTitle, + String aiAddress, LocalDateTime aiRecruitStartAt, LocalDateTime aiRecruitEndAt, LocalDateTime aiStartAt, @@ -40,11 +42,11 @@ public NormalizedActivity copyWithFallbackFields( String aiOrganizer ) { return new NormalizedActivity( - this.title, + isMissing(this.title) && aiTitle != null ? aiTitle : this.title, isMissing(this.description) && aiDescription != null ? aiDescription : this.description, this.thumbnailUrl, this.sourceUrl, - this.address, + isMissing(this.address) && aiAddress != null ? aiAddress : this.address, isMissing(this.organizer) && aiOrganizer != null ? aiOrganizer : this.organizer, isMissing(this.contactInfo) && aiContactInfo != null ? aiContactInfo : this.contactInfo, isMissing(this.target) && aiTarget != null ? aiTarget : this.target, @@ -63,6 +65,19 @@ public NormalizedActivity copyWithFallbackFields( } private boolean isMissing(String value) { - return value == null || value.isBlank() || value.contains("원문 링크") || value.contains("확인하세요"); + if (value == null || value.isBlank()) { + return true; + } + + String compact = value.replaceAll("\\s+", ""); + return compact.contains("원문링크") + || compact.contains("문의안내") + || compact.contains("장소정보") + || compact.contains("참여대상") + || compact.contains("고객센터") + || compact.contains("운영기관") + || compact.contains("확인하세요") + || compact.contains("원문참조") + || compact.contains("상세설명은"); } -} \ No newline at end of file +} diff --git a/src/main/java/com/jjikmeok/app/domain/sync/entity/RawActivity.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/entity/RawActivity.java similarity index 94% rename from src/main/java/com/jjikmeok/app/domain/sync/entity/RawActivity.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/entity/RawActivity.java index c248bd7..fde125a 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/entity/RawActivity.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/entity/RawActivity.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.entity; +package com.jjikmeok.app.domain.activity.publicactivity.entity; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.jjikmeok.app.global.common.BaseEntity; @@ -6,7 +6,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Getter; diff --git a/src/main/java/com/jjikmeok/app/domain/sync/repository/RawActivityRepository.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/repository/RawActivityRepository.java similarity index 50% rename from src/main/java/com/jjikmeok/app/domain/sync/repository/RawActivityRepository.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/repository/RawActivityRepository.java index 67550a7..1dfa36d 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/repository/RawActivityRepository.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/repository/RawActivityRepository.java @@ -1,6 +1,6 @@ -package com.jjikmeok.app.domain.sync.repository; +package com.jjikmeok.app.domain.activity.publicactivity.repository; -import com.jjikmeok.app.domain.sync.entity.RawActivity; +import com.jjikmeok.app.domain.activity.publicactivity.entity.RawActivity; import org.springframework.data.jpa.repository.JpaRepository; public interface RawActivityRepository extends JpaRepository { diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityAttachmentStorageService.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityAttachmentStorageService.java similarity index 98% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivityAttachmentStorageService.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityAttachmentStorageService.java index a260b67..45107f0 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityAttachmentStorageService.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityAttachmentStorageService.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.jjikmeok.app.domain.activity.enums.SourceType; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityDetailEnricher.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityDetailEnricher.java similarity index 99% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivityDetailEnricher.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityDetailEnricher.java index bccc1dc..3329249 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityDetailEnricher.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityDetailEnricher.java @@ -1,14 +1,13 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jjikmeok.app.domain.sync.dto.NormalizedActivity; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.net.URI; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Iterator; import java.util.Locale; diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizer.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizer.java similarity index 86% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizer.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizer.java index c09f525..418aecc 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizer.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizer.java @@ -1,18 +1,17 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.sync.dto.NormalizedActivity; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; -import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; import javax.xml.parsers.DocumentBuilderFactory; @@ -28,7 +27,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -85,26 +83,17 @@ private NormalizedActivity fromNode(SourceType sourceType, String requestUrl, Js "pstWholCn", "ETC_DESC", "PROGRAM", "SUB_DESCRIPTION", "sty" ); - String description = sourceType == SourceType.YOUTH_CONTENT - ? cleanDescriptionOnly(rawDescription) - : utils.cleanDescriptionBody(cleanDescription(rawDescription)); + String description = utils.cleanDescriptionBody(cleanDescription(rawDescription)); String sourceUrl = text(item, "sourceUrl", "URL", "HMPG_ADDR", "ORG_LINK", "홈페이지주소", "SVCURL", "homepage", "eventUrl", "detailUrl", "href", "link", "url", "pstUrlAddr" ); - if (sourceUrl == null && sourceType == SourceType.YOUTH_CONTENT) { - sourceUrl = utils.first(extractHref(rawDescription), youthContentDetailUrl(item)); - } if (sourceUrl == null && sourceType == SourceType.KOPIS) sourceUrl = kopisDetailUrl(item); - if (sourceUrl == null && sourceType == SourceType.VOLUNTEER_1365) sourceUrl = volunteerDetailUrl(item); sourceUrl = utils.normalizeUrl(sourceUrl); String externalId = text(item, "externalId", "contentid", "mt20id", "LOCAL_ID", "progrmRegistNo", "SVCID", "id", "seq", "eventId", "crseId", "pstSn"); - if (sourceType == SourceType.YOUTH_CONTENT) { - externalId = utils.first(youthContentId(item), externalId); - } String rawAddress = text(item, "address", "addr1", "addr2", "fcltynm", "EVENT_SITE", @@ -121,19 +110,6 @@ private NormalizedActivity fromNode(SourceType sourceType, String requestUrl, Js String address = utils.cleanAddressStrict(rawAddress); - if (sourceType == SourceType.VOLUNTEER_1365) { - address = utils.firstText( - utils.extractAddressByLabel(mixedText, "봉사장소", "장소", "주소"), - address - ); - } - - if (sourceType == SourceType.YOUTH_CONTENT) { - address = utils.firstText( - utils.extractAddressByLabel(mixedText, "장소", "진행 장소", "교육장소", "교육장 상세주소", "지역"), - address - ); - } if (sourceType == SourceType.KOPIS) { address = utils.firstText( @@ -191,14 +167,6 @@ private NormalizedActivity fromNode(SourceType sourceType, String requestUrl, Js String categoryHint = text(item, "category", "genrenm", "GENRE", "분류", "realmName", "CODENAME", "codename", "pstSeNm", "MAXCLASSNM", "MINCLASSNM"); - if (sourceType == SourceType.YOUTH_CONTENT && title != null && title.contains(" - ")) { - String[] titleParts = title.split(" - ", 2); - if (!titleParts[1].trim().isBlank()) { - title = titleParts[1].trim(); - if (utils.isBlank(organizer)) organizer = cleanOrganizer(titleParts[0].trim()); - } - } - ActivitySyncUtils.DateRange mixedRange = utils.extractDateRangeFromMixedText(mixedText); Period period = period(String.join(" ", @@ -220,36 +188,6 @@ private NormalizedActivity fromNode(SourceType sourceType, String requestUrl, Js LocalDateTime recruitStartAt = firstDateTime(item, "recruitStartAt", "RCPTBGNDT", "noticeBgnde", "reqstBeginDe", "recruitStartDate", "rceptStartDate"); LocalDateTime recruitEndAt = firstDateTime(item, "recruitEndAt", "RCPTENDDT", "noticeEndde", "reqstEndDe", "recruitEndDate", "rceptEndDate"); - if (sourceType == SourceType.VOLUNTEER_1365) { - ActivitySyncUtils.DateRange volunteerPeriod = utils.extractDateRangeFromMixedText(utils.firstText( - text(item, "봉사기간", "progrmBgnde", "progrmEndde"), - mixedText - )); - ActivitySyncUtils.DateRange recruitPeriod = utils.extractDateRangeFromMixedText(utils.firstText( - text(item, "모집기간", "noticeBgnde", "noticeEndde"), - mixedText - )); - - startAt = utils.firstDateTime(startAt, volunteerPeriod.start(), mixedRange.start()); - endAt = utils.firstDateTime(endAt, volunteerPeriod.end(), mixedRange.end()); - recruitStartAt = utils.firstDateTime(recruitStartAt, recruitPeriod.start()); - recruitEndAt = utils.firstDateTime(recruitEndAt, recruitPeriod.end()); - } - - if (sourceType == SourceType.YOUTH_CONTENT) { - if (recruitStartAt == null || recruitEndAt == null) { - recruitStartAt = utils.firstDateTime(recruitStartAt, mixedRange.start()); - recruitEndAt = utils.firstDateTime(recruitEndAt, mixedRange.end()); - } - - ActivitySyncUtils.DateRange activityRange = utils.extractDateRangeFromMixedText(utils.firstText( - text(item, "일경험 기간", "활동기간", "교육기간", "사업일정"), - mixedText - )); - - startAt = utils.firstDateTime(startAt, activityRange.start()); - endAt = utils.firstDateTime(endAt, activityRange.end()); - } if (sourceType == SourceType.EXHIBITION) { startAt = utils.firstDateTime(startAt, period.startAt(), mixedRange.start()); @@ -277,7 +215,9 @@ private NormalizedActivity fromNode(SourceType sourceType, String requestUrl, Js if (sourceUrl == null) sourceUrl = requestUrl; if (title == null) title = sourceType.name() + " activity"; if (description == null) description = DEFAULT_DESCRIPTION; - if (externalId == null) externalId = hash(sourceType.name() + sourceUrl + title); + if (externalId == null) { + externalId = createFallbackExternalId(sourceType, sourceUrl, title); + } String status = text(item, "status", @@ -545,7 +485,11 @@ private boolean contains(String value, String... keywords) { return false; } - private String hash(String value) { + private String createFallbackExternalId(SourceType sourceType, String sourceUrl, String title) { + return hashExternalIdSeed(sourceType.name() + "|" + sourceUrl + "|" + title); + } + + private String hashExternalIdSeed(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] h = digest.digest(value.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityRegionResolver.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityRegionResolver.java similarity index 98% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivityRegionResolver.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityRegionResolver.java index 86725c5..c23dad9 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivityRegionResolver.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityRegionResolver.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.jjikmeok.app.domain.region.entity.Region; import com.jjikmeok.app.domain.region.repository.RegionRepository; diff --git a/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncScheduler.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncScheduler.java new file mode 100644 index 0000000..c0d4da9 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncScheduler.java @@ -0,0 +1,38 @@ +package com.jjikmeok.app.domain.activity.publicactivity.service; + +import com.jjikmeok.app.domain.activity.enums.SourceType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ActivitySyncScheduler { + + private final ActivitySyncService activitySyncService; + + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + public void syncDailySources() { + log.info("[ActivitySync] 공공 API 일일 동기화를 시작합니다."); + long startTime = System.currentTimeMillis(); + + sync(SourceType.KOPIS, SourceType.EXHIBITION, SourceType.SEOUL_CULTURE, SourceType.SEOUL_RESERVATION); + + long duration = System.currentTimeMillis() - startTime; + log.info("[ActivitySync] 공공 API 일일 동기화를 완료했습니다. 소요시간={}ms", duration); + } + + private void sync(SourceType... sourceTypes) { + for (SourceType sourceType : sourceTypes) { + try { + log.info("[ActivitySync] {} 동기화를 시작합니다.", sourceType); + activitySyncService.sync(sourceType, null); + log.info("[ActivitySync] {} 동기화를 완료했습니다.", sourceType); + } catch (Exception e) { + log.error("[ActivitySync] {} 동기화 중 오류가 발생했습니다. {}", sourceType, e.getMessage(), e); + } + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncService.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncService.java similarity index 76% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncService.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncService.java index 195baeb..5cb0289 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncService.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncService.java @@ -1,8 +1,8 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.sync.dto.ActivitySyncResponse; +import com.jjikmeok.app.domain.activity.publicactivity.dto.ActivitySyncResponse; public interface ActivitySyncService { diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncServiceImpl.java similarity index 78% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImpl.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncServiceImpl.java index f857414..85af593 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncServiceImpl.java @@ -1,18 +1,15 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; import com.jjikmeok.app.domain.ai.dto.ExtractedActivityDto; import com.jjikmeok.app.domain.ai.service.AiActivityParser; -import com.jjikmeok.app.domain.sync.dto.ActivitySyncResponse; -import com.jjikmeok.app.domain.sync.dto.NormalizedActivity; -import com.jjikmeok.app.domain.sync.entity.RawActivity; -import com.jjikmeok.app.domain.sync.repository.RawActivityRepository; +import com.jjikmeok.app.domain.activity.service.ActivityTagAutoAttachService; +import com.jjikmeok.app.domain.activity.privateactivity.sheets.GoogleSheetsService; +import com.jjikmeok.app.domain.activity.publicactivity.dto.ActivitySyncResponse; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; import com.jjikmeok.app.domain.region.entity.Region; import com.jjikmeok.app.domain.region.repository.RegionRepository; import com.jjikmeok.app.global.common.exception.CustomException; @@ -40,20 +37,32 @@ public class ActivitySyncServiceImpl implements ActivitySyncService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final int DEFAULT_PAGE_SIZE = 100; - private static final int YOUTH_CONTENT_PAGE_SIZE = 10; private static final int HARD_MAX_PAGES = 30; private static final int HARD_MAX_MONTHS_AHEAD = 12; private static final LocalDateTime ALWAYS_OPEN_AT = LocalDateTime.of(2999, 12, 31, 23, 59, 59); + private static final String DEFAULT_DESCRIPTION = "\uC0C1\uC138 \uC124\uBA85\uC740 \uC6D0\uBB38\uC5D0\uC11C \uD655\uC778\uD558\uC138\uC694."; + private static final String DEFAULT_ORGANIZER = "\uC8FC\uCD5C\uAE30\uAD00 \uC815\uBCF4\uB294 \uC6D0\uBB38 \uB9C1\uD06C\uB97C \uD655\uC778\uD558\uC138\uC694."; + private static final String DEFAULT_CONTACT_INFO = "\uBB38\uC758 \uC548\uB0B4\uB294 \uC6D0\uBB38 \uB9C1\uD06C\uB97C \uD655\uC778\uD558\uC138\uC694."; + private static final String DEFAULT_TARGET = "\uCC38\uC5EC \uB300\uC0C1\uC740 \uC6D0\uBB38 \uB9C1\uD06C\uB97C \uD655\uC778\uD558\uC138\uC694."; + private static final List MISSING_TEXT_MARKERS = List.of( + DEFAULT_DESCRIPTION, + DEFAULT_ORGANIZER, + DEFAULT_CONTACT_INFO, + DEFAULT_TARGET, + "\uC6D0\uBB38\uC5D0\uC11C \uD655\uC778\uD558\uC138\uC694.", + "\uC6D0\uBB38 \uB9C1\uD06C\uB97C \uD655\uC778\uD558\uC138\uC694." + ); private final ActivityRegionResolver activityRegionResolver; private final ExternalActivityGateway externalActivityGateway; private final ActivityNormalizer activityNormalizer; - private final RawActivityRepository rawActivityRepository; + private final RawActivityArchiveService rawActivityArchiveService; private final ActivityRepository activityRepository; private final RegionRepository regionRepository; - private final ObjectMapper objectMapper; + private final GoogleSheetsService googleSheetsService; private final ActivityAttachmentStorageService activityAttachmentStorageService; private final ActivityDetailEnricher activityDetailEnricher; + private final ActivityTagAutoAttachService activityTagAutoAttachService; private final ActivitySyncUtils utils; private final AiActivityParser aiActivityParser; private final VectorStore vectorStore; @@ -61,22 +70,12 @@ public class ActivitySyncServiceImpl implements ActivitySyncService { @Value("${app.activity-sync.default-region-id:1}") private Long defaultRegionId; @Value("${app.activity-sync.default-max-pages:1}") private Integer defaultMaxPages; @Value("${app.activity-sync.months-ahead:1}") private Integer monthsAhead; - @Value("${app.activity-sync.tour-api.base-url:}") private String tourApiBaseUrl; - @Value("${app.activity-sync.tour-api.service-key:}") private String tourApiServiceKey; - @Value("${app.activity-sync.tour-api.max-pages:1}") private Integer tourApiMaxPages; @Value("${app.activity-sync.kopis.base-url:}") private String kopisBaseUrl; @Value("${app.activity-sync.kopis.service-key:}") private String kopisServiceKey; @Value("${app.activity-sync.kopis.max-pages:1}") private Integer kopisMaxPages; @Value("${app.activity-sync.exhibition.base-url:}") private String exhibitionBaseUrl; @Value("${app.activity-sync.exhibition.service-key:}") private String exhibitionServiceKey; @Value("${app.activity-sync.exhibition.max-pages:1}") private Integer exhibitionMaxPages; - @Value("${app.activity-sync.volunteer-1365.base-url:}") private String volunteer1365BaseUrl; - @Value("${app.activity-sync.volunteer-1365.service-key:}") private String volunteer1365ServiceKey; - @Value("${app.activity-sync.volunteer-1365.max-pages:1}") private Integer volunteer1365MaxPages; - @Value("${app.activity-sync.volunteer-1365.default-thumbnail-url:/images/defaults/volunteer-1365.png}") private String volunteer1365DefaultThumbnailUrl; - @Value("${app.activity-sync.youth-content.base-url:}") private String youthContentBaseUrl; - @Value("${app.activity-sync.youth-content.service-key:}") private String youthContentServiceKey; - @Value("${app.activity-sync.youth-content.max-pages:1}") private Integer youthContentMaxPages; @Value("${app.activity-sync.seoul-culture.base-url:}") private String seoulCultureBaseUrl; @Value("${app.activity-sync.seoul-culture.max-pages:1}") private Integer seoulCultureMaxPages; @Value("${app.activity-sync.seoul-reservation.base-url:}") private String seoulReservationBaseUrl; @@ -87,10 +86,10 @@ public class ActivitySyncServiceImpl implements ActivitySyncService { @Transactional public void syncAllSources() { log.info("[ActivitySync] 7개 소스 일괄 동기화 시작"); - for (SourceType source : List.of( - SourceType.TOUR_API, SourceType.KOPIS, SourceType.EXHIBITION, - SourceType.VOLUNTEER_1365, SourceType.YOUTH_CONTENT, - SourceType.SEOUL_CULTURE, SourceType.SEOUL_RESERVATION)) { + for (SourceType source : SourceType.values()) { + if (!source.isPublicApiSource()) { + continue; + } try { sync(source, null, null); } catch (Exception e) { @@ -137,15 +136,12 @@ public ActivitySyncResponse sync(SourceType sourceType, ActivityCategory categor return new ActivitySyncResponse(sourceType, rawSaved, saved, duplicated); } - rawActivityRepository.save(RawActivity.create( - sourceType, null, - fetchedPayload.requestUrl(), fetchedPayload.contentType(), - rawPayload(sourceType, fetchedPayload.contentType(), fetchedPayload.payload()))); + rawActivityArchiveService.archiveFetchedPayload(fetchedPayload); rawSaved++; - int pageSize = sourceType == SourceType.YOUTH_CONTENT ? YOUTH_CONTENT_PAGE_SIZE : DEFAULT_PAGE_SIZE; List normalizedActivities = activityNormalizer.normalize( sourceType, fetchedPayload.requestUrl(), fetchedPayload.contentType(), fetchedPayload.payload()); + int pageSize = normalizedActivities.size(); for (NormalizedActivity na : normalizedActivities) { totalCount++; @@ -162,6 +158,8 @@ public ActivitySyncResponse sync(SourceType sourceType, ActivityCategory categor ExtractedActivityDto aiResult = aiActivityParser.parseFallback(aiContext(sourceType, na), sourceType); if (aiResult != null) { na = na.copyWithFallbackFields( + safeAiText(aiResult.title(), na.title()), + safeAiText(aiResult.address(), na.address()), safeDate(aiResult.recruitStartAt()), safeDate(aiResult.recruitEndAt()), safeDate(aiResult.startAt()), @@ -186,12 +184,15 @@ public ActivitySyncResponse sync(SourceType sourceType, ActivityCategory categor if (existing != null) { Region region = activityRegionResolver.resolve(na.title(), na.address(), defaultRegionId); updateIfChanged(existing, region, na, categoryOverride); + googleSheetsService.upsertPublicActivity(na); duplicated++; continue; } Region resolvedRegion = activityRegionResolver.resolve(na.title(), na.address(), defaultRegionId); Activity savedActivity = activityRepository.save(toActivity(resolvedRegion, na, categoryOverride)); + activityTagAutoAttachService.refresh(savedActivity); + googleSheetsService.upsertPublicActivity(na); saved++; try { @@ -205,7 +206,7 @@ public ActivitySyncResponse sync(SourceType sourceType, ActivityCategory categor } } - if (pageSize < (sourceType == SourceType.YOUTH_CONTENT ? YOUTH_CONTENT_PAGE_SIZE : DEFAULT_PAGE_SIZE)) break; + if (pageSize < DEFAULT_PAGE_SIZE) break; } } } @@ -222,7 +223,6 @@ private String safeAiText(String aiValue, String originalValue) { /** * AI 보완이 필요한 항목인지 판별합니다. - * 핵심 필드 또는 부가 필드가 누락되었거나, YOUTH_CONTENT 특이 케이스인 경우 true를 반환합니다. */ private boolean requiresAiFallback(SourceType source, NormalizedActivity activity) { boolean missingCore = activity.title() == null || activity.title().isBlank() @@ -232,19 +232,14 @@ private boolean requiresAiFallback(SourceType source, NormalizedActivity activit || activity.recruitEndAt().getYear() >= 2099 || activity.price() == null; - boolean missingExtra = isMissingText(activity.organizer()) - || isMissingText(activity.description()) - || isMissingText(activity.target()) - || isMissingText(activity.contactInfo()); + boolean missingExtra = isBlankOrFallbackText(activity.organizer()) + || isBlankOrFallbackText(activity.description()) + || isBlankOrFallbackText(activity.target()) + || isBlankOrFallbackText(activity.contactInfo()) + || isBlankOrFallbackText(activity.address()); if (missingCore || missingExtra) return true; - if (source == SourceType.YOUTH_CONTENT && activity.title() != null) { - return activity.title().contains(",") - || activity.title().contains("_") - || activity.title().contains("참여 안내"); - } - return false; } @@ -255,10 +250,10 @@ private LocalDateTime safeDate(LocalDateTime date) { private NormalizedActivity withDefaults(NormalizedActivity activity) { if (activity == null) return null; - String description = isMissingText(activity.description()) ? "상세 설명은 원문에서 확인하세요." : activity.description(); - String organizer = isMissingText(activity.organizer()) ? "주최기관 정보는 원문 링크를 확인하세요." : activity.organizer(); - String contactInfo = utils.firstText(utils.contactOnly(activity.contactInfo()), "문의 안내는 원문 링크를 확인하세요."); - String target = isMissingText(activity.target()) ? "참여 대상은 원문 링크를 확인하세요." : activity.target(); + String description = isBlankOrFallbackText(activity.description()) ? DEFAULT_DESCRIPTION : activity.description(); + String organizer = isBlankOrFallbackText(activity.organizer()) ? DEFAULT_ORGANIZER : activity.organizer(); + String contactInfo = utils.firstText(utils.contactOnly(activity.contactInfo()), DEFAULT_CONTACT_INFO); + String target = isBlankOrFallbackText(activity.target()) ? DEFAULT_TARGET : activity.target(); String thumbnail = utils.isBlank(activity.thumbnailUrl()) ? serverBaseUrl + "/images/defaults/default-activity.png" : activity.thumbnailUrl(); int price = activity.price() == null ? 0 : Math.max(0, activity.price()); @@ -299,6 +294,8 @@ private boolean validForPersist(NormalizedActivity activity) { return activity != null && !utils.isBlank(activity.title()) && !utils.isBlank(activity.sourceUrl()) + && activity.activityType() != null + && activity.category() != null && activity.startAt() != null && activity.endAt() != null && activity.recruitStartAt() != null @@ -313,6 +310,21 @@ private boolean isMissingText(String value) { || value.contains("원문 참조") || value.contains("상세 설명은"); } + private boolean isBlankOrFallbackText(String value) { + if (value == null || value.isBlank()) { + return true; + } + + String normalized = value.replaceAll("\\s+", " ").trim(); + if (MISSING_TEXT_MARKERS.contains(normalized)) { + return true; + } + + return normalized.contains("\uC6D0\uBB38\uC5D0\uC11C \uD655\uC778\uD558\uC138\uC694") + || normalized.contains("\uC6D0\uBB38 \uB9C1\uD06C\uB97C \uD655\uC778\uD558\uC138\uC694") + || isMissingText(normalized); + } + private String aiContext(SourceType sourceType, NormalizedActivity activity) { return """ [수집 채널]: %s @@ -357,18 +369,7 @@ private NormalizedActivity enrichThumbnail(SourceType sourceType, NormalizedActi } private String defaultThumbnail(SourceType sourceType, NormalizedActivity activity) { - if (sourceType == SourceType.VOLUNTEER_1365) { - String title = activity.title() != null ? activity.title() : ""; - String base = serverBaseUrl + "/images/defaults/volunteer-"; - if (title.contains("환경") || title.contains("청소") || title.contains("줍깅") || title.contains("정화") || title.contains("플로깅")) return base + "environment.png"; - if (title.contains("어르신") || title.contains("노인") || title.contains("요양") || title.contains("말벗")) return base + "elderly.png"; - if (title.contains("아동") || title.contains("어린이") || title.contains("학습") || title.contains("멘토링") || title.contains("교육")) return base + "education.png"; - if (title.contains("동물") || title.contains("유기견") || title.contains("보호소")) return base + "animal.png"; - if (title.contains("헌혈") || title.contains("연탄") || title.contains("도시락") || title.contains("배달")) return base + "delivery.png"; - if (title.contains("안내") || title.contains("행사") || title.contains("축제") || title.contains("진행")) return base + "event.png"; - return volunteer1365DefaultThumbnailUrl.startsWith("http") ? volunteer1365DefaultThumbnailUrl : serverBaseUrl + volunteer1365DefaultThumbnailUrl; - } - return sourceType == SourceType.YOUTH_CONTENT ? serverBaseUrl + "/images/defaults/youth-content.png" : null; + return null; } private NormalizedActivity withThumbnail(NormalizedActivity a, String url) { @@ -405,10 +406,12 @@ private void updateIfChanged(Activity activity, Region region, NormalizedActivit ActivityCategory category = categoryOverride != null ? categoryOverride : merged.category(); if (!changed(activity, merged, active) && Objects.equals(activity.getCategory(), category)) return; activity.update(region, merged.title(), merged.description(), merged.thumbnailUrl(), merged.sourceUrl(), - merged.address(), merged.startAt(), merged.endAt(), merged.recruitStartAt(), merged.recruitEndAt(), + merged.address(), merged.organizer(), merged.contactInfo(), merged.target(), + merged.startAt(), merged.endAt(), merged.recruitStartAt(), merged.recruitEndAt(), merged.price(), merged.activityType(), category, merged.sourceType(), merged.externalId(), merged.approvalStatus(), active); activity.updateExtra(merged.organizer(), merged.contactInfo(), merged.target()); + activityTagAutoAttachService.refresh(activity); } private NormalizedActivity mergeForUpdate(Activity existing, NormalizedActivity incoming) { @@ -436,7 +439,7 @@ private NormalizedActivity mergeForUpdate(Activity existing, NormalizedActivity } private String betterText(String existing, String incoming) { - return !isMissingText(incoming) ? incoming : existing; + return !isBlankOrFallbackText(incoming) ? incoming : existing; } private String betterContact(String existing, String incoming) { @@ -515,46 +518,18 @@ private List windows(SourceType sourceType, LocalDate today, LocalDa private Integer configuredMaxPages(SourceType sourceType) { return switch (sourceType) { - case TOUR_API -> tourApiMaxPages; case KOPIS -> kopisMaxPages; case EXHIBITION -> exhibitionMaxPages; - case VOLUNTEER_1365 -> volunteer1365MaxPages; - case YOUTH_CONTENT -> youthContentMaxPages; case SEOUL_CULTURE -> seoulCultureMaxPages; case SEOUL_RESERVATION -> seoulReservationMaxPages; default -> defaultMaxPages; }; } - private String rawPayload(SourceType sourceType, String contentType, String payload) { - if (sourceType != SourceType.YOUTH_CONTENT || !"JSON".equals(contentType) || utils.isBlank(payload)) return payload; - try { - JsonNode root = objectMapper.readTree(payload); - stripLargeAttachments(root); - return objectMapper.writeValueAsString(root); - } catch (Exception e) { - return payload.replaceAll("\"atchFile\"\\s*:\\s*\"data:image/[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"", "\"atchFile\":null"); - } - } - - private void stripLargeAttachments(JsonNode node) { - if (node == null) return; - if (node.isObject()) { - ObjectNode objectNode = (ObjectNode) node; - if (objectNode.has("atchFile")) objectNode.putNull("atchFile"); - objectNode.fields().forEachRemaining(entry -> stripLargeAttachments(entry.getValue())); - } else if (node.isArray()) { - node.forEach(this::stripLargeAttachments); - } - } - private String baseUrl(SourceType src) { return switch (src) { - case TOUR_API -> tourApiBaseUrl; case KOPIS -> kopisBaseUrl; case EXHIBITION -> exhibitionBaseUrl; - case VOLUNTEER_1365 -> volunteer1365BaseUrl; - case YOUTH_CONTENT -> youthContentBaseUrl; case SEOUL_CULTURE -> seoulCultureBaseUrl; case SEOUL_RESERVATION -> seoulReservationBaseUrl; default -> throw new CustomException(ErrorCode.ACTIVITY_SYNC_UNSUPPORTED_SOURCE); @@ -563,14 +538,11 @@ private String baseUrl(SourceType src) { private String serviceKey(SourceType src) { return switch (src) { - case TOUR_API -> tourApiServiceKey; case KOPIS -> kopisServiceKey; case EXHIBITION -> exhibitionServiceKey; - case VOLUNTEER_1365 -> volunteer1365ServiceKey; - case YOUTH_CONTENT -> youthContentServiceKey; default -> ""; }; } private record DateWindow(LocalDate start, LocalDate end) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncUtils.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncUtils.java similarity index 94% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncUtils.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncUtils.java index 2886d4f..f86527a 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncUtils.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivitySyncUtils.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import org.springframework.stereotype.Component; import org.springframework.web.util.HtmlUtils; @@ -71,7 +71,7 @@ public String cleanText(String value) { } public String repairMojibake(String value) { - if (value == null || !value.matches(".*[ÃÂìíëê][\\u0080-\\u00ff]?.*")) return value; + if (!looksMojibake(value)) return value; CharsetEncoder encoder = StandardCharsets.ISO_8859_1.newEncoder(); if (!encoder.canEncode(value)) return value; @@ -80,6 +80,28 @@ public String repairMojibake(String value) { return hangulCount(repaired) > hangulCount(value) ? repaired : value; } + private boolean looksMojibake(String value) { + if (value == null || value.isBlank()) { + return false; + } + + for (int i = 0; i < value.length() - 1; i++) { + char current = value.charAt(i); + char next = value.charAt(i + 1); + boolean marker = + current == '\u00C3' + || current == '\u00C2' + || current == '\u00EC' + || current == '\u00ED' + || current == '\u00EB' + || current == '\u00EA'; + if (marker && next >= '\u0080' && next <= '\u00FF') { + return true; + } + } + return false; + } + private int hangulCount(String value) { int count = 0; for (int i = 0; i < value.length(); i++) { @@ -368,4 +390,4 @@ private String trim(String value, int max) { if (value == null) return null; return value.length() > max ? value.substring(0, max).trim() : value; } -} \ No newline at end of file +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifier.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifier.java new file mode 100644 index 0000000..97c2c57 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifier.java @@ -0,0 +1,126 @@ +package com.jjikmeok.app.domain.activity.publicactivity.service; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Locale; + +@Component +public class CategoryClassifier { + + private static final int INVALID_FUTURE_YEAR = 2099; + + public ActivityCategory classifyCategory(SourceType sourceType, String text) { + String value = normalize(text); + + if (sourceType == SourceType.KOPIS) { + return ActivityCategory.CULTURE; + } + + if (sourceType == SourceType.EXHIBITION) { + if (containsAny(value, "사진", "영상", "촬영", "미디어", "필름", "다큐", "포토")) { + return ActivityCategory.PHOTO_VIDEO; + } + if (containsAny(value, "공예", "만들기", "도예", "뜨개", "가죽", "목공", "핸드메이드")) { + return ActivityCategory.CRAFT; + } + return ActivityCategory.CULTURE; + } + + return classifyByKeywords(value); + } + + public ActivityType classifyType(SourceType sourceType, String text) { + return classifyType(sourceType, text, null, null); + } + + public ActivityType classifyType(SourceType sourceType, String text, LocalDateTime startAt, LocalDateTime endAt) { + String value = normalize(text); + + if (validDate(startAt) && validDate(endAt)) { + long days = ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()) + 1; + if (days > 1) { + if (containsAny(value, "프로그램", "과정", "코스", "정규", "기수", "클래스", "교육", "스터디")) { + return ActivityType.PROGRAM; + } + if (containsAny(value, "행사", "공연", "축제", "강연", "전시", "박람회")) { + return ActivityType.EVENT; + } + return ActivityType.PROGRAM; + } + if (days == 1 && containsAny(value, "원데이", "원 데이", "1일", "하루", "체험")) { + return ActivityType.ONE_DAY; + } + } + + if (containsAny(value, "동아리", "모임", "커뮤니티", "크루", "정기모임", "클럽")) { + return ActivityType.CLUB; + } + if (containsAny(value, "프로그램", "과정", "코스", "정규", "기수", "클래스", "교육", "스터디")) { + return ActivityType.PROGRAM; + } + if (containsAny(value, "행사", "공연", "축제", "강연", "전시", "박람회")) { + return ActivityType.EVENT; + } + if (containsAny(value, "원데이", "원 데이", "1일", "하루", "체험")) { + return ActivityType.ONE_DAY; + } + + return ActivityType.EVENT; + } + + private ActivityCategory classifyByKeywords(String value) { + if (containsAny(value, "요리", "베이킹", "쿠킹", "디저트", "제과", "제빵")) { + return ActivityCategory.COOKING; + } + if (containsAny(value, "공예", "만들기", "도예", "뜨개", "가죽", "목공", "핸드메이드")) { + return ActivityCategory.CRAFT; + } + if (containsAny(value, "운동", "액티비티", "러닝", "등산", "요가", "필라테스", "스포츠", "클라이밍")) { + return ActivityCategory.SPORTS; + } + if (containsAny(value, "사진", "영상", "촬영", "미디어", "필름", "다큐", "포토")) { + return ActivityCategory.PHOTO_VIDEO; + } + if (containsAny(value, "책", "글", "독서", "글쓰기", "작문", "인문", "문학")) { + return ActivityCategory.HUMANITIES; + } + if (containsAny(value, "여행", "탐방", "투어", "트립", "캠프", "답사")) { + return ActivityCategory.TRAVEL; + } + if (containsAny(value, "언어", "영어", "일본어", "중국어", "회화", "글로벌", "해외")) { + return ActivityCategory.LANGUAGE; + } + if (containsAny(value, "봉사", "기부", "나눔", "사회공헌", "자원봉사")) { + return ActivityCategory.VOLUNTEER; + } + if (containsAny(value, "성장", "커리어", "취업", "직무", "브랜딩", "마케팅", "포트폴리오", "창업")) { + return ActivityCategory.CAREER; + } + if (containsAny(value, "문화", "예술", "공연", "전시", "뮤지컬", "연극", "콘서트", "미술")) { + return ActivityCategory.CULTURE; + } + return null; + } + + private boolean validDate(LocalDateTime value) { + return value != null && value.getYear() < INVALID_FUTURE_YEAR; + } + + private String normalize(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } + + private boolean containsAny(String value, String... keywords) { + for (String keyword : keywords) { + if (value.contains(keyword.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGateway.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGateway.java similarity index 87% rename from src/main/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGateway.java rename to src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGateway.java index fcfd42e..594bc50 100644 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGateway.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGateway.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.jjikmeok.app.global.common.exception.CustomException; @@ -27,7 +27,6 @@ public class ExternalActivityGateway { private final RestClient restClient = RestClient.create(); private static final int DEFAULT_PAGE_SIZE = 100; - private static final int YOUTH_CONTENT_PAGE_SIZE = 10; private static final int MAX_REQUEST_ATTEMPTS = 3; public FetchedPayload fetch(SourceType sourceType, String baseUrl, String serviceKey) { @@ -103,19 +102,40 @@ private String decodePayload(byte[] body, MediaType contentType) { if (cs == null) cs = StandardCharsets.UTF_8; String decoded = new String(body, cs); - if (decoded.matches(".*[ÃÂìíëê][\\u0080-\\u00ff]?.*")) { + if (looksMojibake(decoded)) { try { return new String(decoded.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8); } catch (Exception ignored) {} } return decoded; } + private boolean looksMojibake(String value) { + if (value == null || value.isBlank()) { + return false; + } + + for (int i = 0; i < value.length() - 1; i++) { + char current = value.charAt(i); + char next = value.charAt(i + 1); + boolean marker = + current == '\u00C3' + || current == '\u00C2' + || current == '\u00EC' + || current == '\u00ED' + || current == '\u00EB' + || current == '\u00EA'; + if (marker && next >= '\u0080' && next <= '\u00FF') { + return true; + } + } + return false; + } + String buildUrl(SourceType sourceType, String baseUrl, String serviceKey, LocalDate today, LocalDate endDate, int page) { return buildUrl(sourceType, baseUrl, serviceKey, today, endDate, page, null); } String buildUrl(SourceType sourceType, String baseUrl, String serviceKey, LocalDate today, LocalDate endDate, int page, String prfstate) { if (sourceType == SourceType.SEOUL_CULTURE || sourceType == SourceType.SEOUL_RESERVATION) baseUrl = baseUrl.replaceFirst("/\\d+/\\d+/?$", ""); - if (sourceType == SourceType.VOLUNTEER_1365) baseUrl = baseUrl.replaceFirst("/getVltr(AreaList|PartcptnItem)$", "").replaceFirst("/$", "") + "/getVltrAreaList"; UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(baseUrl); String keyName = keyName(sourceType, baseUrl); @@ -125,17 +145,8 @@ String buildUrl(SourceType sourceType, String baseUrl, String serviceKey, LocalD String endYmd = (sourceType == SourceType.KOPIS && today.plusDays(31).isBefore(endDate) ? today.plusDays(31) : endDate).format(DateTimeFormatter.BASIC_ISO_DATE); switch (sourceType) { - case TOUR_API -> builder.replaceQueryParam("numOfRows", 100).replaceQueryParam("MobileOS", "ETC").replaceQueryParam("MobileApp", "JJickmeok").replaceQueryParam("_type", "json").replaceQueryParam("eventStartDate", ymd).replaceQueryParam("eventEndDate", endYmd).replaceQueryParam("pageNo", page); case KOPIS -> builder.replaceQueryParam("rows", 100).replaceQueryParam("stdate", ymd).replaceQueryParam("eddate", endYmd).replaceQueryParam("cpage", page).replaceQueryParam("prfstate", prfstate == null ? "02" : prfstate); case EXHIBITION -> builder.replaceQueryParam("numOfRows", DEFAULT_PAGE_SIZE).replaceQueryParam("pageNo", page); - case VOLUNTEER_1365 -> builder.replaceQueryParam("numOfRows", DEFAULT_PAGE_SIZE).replaceQueryParam("pageNo", page).replaceQueryParam("progrmBgnde", ymd).replaceQueryParam("progrmEndde", endYmd); - case YOUTH_CONTENT -> { - if ("openApiVlak".equals(keyName)) { - builder.replaceQueryParam("pageIndex", page).replaceQueryParam("display", YOUTH_CONTENT_PAGE_SIZE); - } else { - builder.replaceQueryParam("pageNum", page).replaceQueryParam("pageSize", YOUTH_CONTENT_PAGE_SIZE).replaceQueryParam("rtnType", "json"); - } - } case SEOUL_CULTURE -> builder.pathSegment(String.valueOf((page - 1) * 100 + 1), String.valueOf(page * 100)); case SEOUL_RESERVATION -> builder.pathSegment(String.valueOf((page - 1) * 100 + 1), String.valueOf(page * 100)).replaceQueryParam("sortStdr", 1); } @@ -154,7 +165,6 @@ String buildUrl(SourceType sourceType, String baseUrl, String serviceKey, LocalD private String keyName(SourceType sourceType, String baseUrl) { if (sourceType == SourceType.KOPIS) return "service"; - if (sourceType == SourceType.YOUTH_CONTENT) return baseUrl.contains("/opi/") ? "openApiVlak" : "apiKeyNm"; return "serviceKey"; } diff --git a/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/RawActivityArchiveService.java b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/RawActivityArchiveService.java new file mode 100644 index 0000000..3b641aa --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/publicactivity/service/RawActivityArchiveService.java @@ -0,0 +1,103 @@ +package com.jjikmeok.app.domain.activity.publicactivity.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.publicactivity.entity.RawActivity; +import com.jjikmeok.app.domain.activity.publicactivity.repository.RawActivityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class RawActivityArchiveService { + + private static final String DISCOVERY_CONTENT_TYPE = "DISCOVERY_JSON"; + + private final RawActivityRepository rawActivityRepository; + private final ObjectMapper objectMapper; + + @Transactional + public void archiveFetchedPayload(ExternalActivityGateway.FetchedPayload fetchedPayload) { + if (fetchedPayload == null) { + return; + } + + rawActivityRepository.save(RawActivity.create( + fetchedPayload.sourceType(), + null, + fetchedPayload.requestUrl(), + fetchedPayload.contentType(), + fetchedPayload.payload() + )); + } + + @Transactional + public void archiveDiscoveryCandidate(SearchResultDto searchResult, DiscoveryCandidateDto candidate) { + if (searchResult == null && candidate == null) { + return; + } + + rawActivityRepository.save(RawActivity.create( + SourceType.DISCOVERY, + createDiscoveryArchiveExternalId(searchResult, candidate), + firstText( + candidate == null ? null : candidate.sourceUrl(), + searchResult == null ? null : searchResult.url() + ), + DISCOVERY_CONTENT_TYPE, + writeDiscoveryPayload(searchResult, candidate) + )); + } + + private String writeDiscoveryPayload(SearchResultDto searchResult, DiscoveryCandidateDto candidate) { + Map payload = new LinkedHashMap<>(); + payload.put("searchResult", searchResult); + payload.put("candidate", candidate); + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + return payload.toString(); + } + } + + private String createDiscoveryArchiveExternalId(SearchResultDto searchResult, DiscoveryCandidateDto candidate) { + return hashExternalIdSeed(firstText( + candidate == null ? null : candidate.sourceUrl(), + searchResult == null ? null : searchResult.url(), + candidate == null ? null : candidate.title(), + searchResult == null ? null : searchResult.title() + )); + } + + private String hashExternalIdSeed(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < 12 && i < hash.length; i++) { + builder.append(String.format("%02x", hash[i])); + } + return builder.toString(); + } catch (Exception e) { + return Integer.toHexString((value == null ? "" : value).hashCode()); + } + } + + private String firstText(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityFavoriteRepository.java b/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityFavoriteRepository.java deleted file mode 100644 index d442b14..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityFavoriteRepository.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.jjikmeok.app.domain.activity.repository; - -import com.jjikmeok.app.domain.activity.entity.ActivityFavorite; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import java.util.Collection; -import java.util.List; -import java.util.Optional; - -public interface ActivityFavoriteRepository extends JpaRepository { - - List findAllByUserIdOrderByCreatedAtDesc(Long userId); - - Optional findByUserIdAndActivityId(Long userId, Long activityId); - - boolean existsByUserIdAndActivityId(Long userId, Long activityId); - - @Query(""" - SELECT f.activity.id - FROM ActivityFavorite f - WHERE f.user.id = :userId - AND f.activity.id IN :activityIds - """) - List findActivityIdsByUserIdAndActivityIdIn( - @Param("userId") Long userId, - @Param("activityIds") Collection activityIds); -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityRepository.java b/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityRepository.java index a22dee2..d367b03 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityRepository.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityRepository.java @@ -1,12 +1,13 @@ package com.jjikmeok.app.domain.activity.repository; -import com.jjikmeok.app.domain.activity.dto.response.ActivityRecommendationCandidateResponse; -import com.jjikmeok.app.domain.activity.dto.response.ActivityRecommendationResponse; import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.dto.response.ActivityRecommendationCandidateResponse; +import com.jjikmeok.app.domain.activity.dto.response.ActivityRecommendationResponse; +import com.jjikmeok.app.domain.favorite.entity.Favorite; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -251,7 +252,7 @@ CASE WHEN COUNT(f.id) > 0 THEN true ELSE false END FROM ActivityTag at JOIN at.activity a JOIN UserOnboardingTag uot ON uot.tag = at.tag - LEFT JOIN ActivityFavorite f ON f.activity = a AND f.user.id = :userId + LEFT JOIN Favorite f ON f.activity = a AND f.user.id = :userId WHERE uot.userOnboarding.user.id = :userId AND a.isActive = true AND a.approvalStatus = :approvalStatus @@ -344,6 +345,12 @@ private List sortActivitiesByIds(List activities, List Optional findFirstByTitleAndStartAtAndAddress(String title, LocalDateTime startAt, String address); + Optional findFirstByTitleIgnoreCaseAndOrganizerIgnoreCase(String title, String organizer); + + List findTop50ByTitleContainingIgnoreCaseOrOrganizerContainingIgnoreCaseOrderByCreatedAtDesc( + String title, + String organizer); + default Optional findDuplicate(SourceType sourceType, String externalId, String sourceUrl, String title, LocalDateTime startAt, String address) { if (externalId != null && !externalId.isBlank()) { diff --git a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityReviewRepository.java b/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityReviewRepository.java deleted file mode 100644 index b5afef6..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/repository/ActivityReviewRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.jjikmeok.app.domain.activity.repository; - -import com.jjikmeok.app.domain.activity.entity.ActivityReview; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface ActivityReviewRepository extends JpaRepository { - Page findAllByActivityId(Long activityId, Pageable pageable); - Optional findByIdAndActivityIdAndUserId(Long id, Long activityId, Long userId); - boolean existsByUserIdAndActivityId(Long userId, Long activityId); -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteService.java deleted file mode 100644 index 0165636..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteService.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.jjikmeok.app.domain.activity.service; - -import com.jjikmeok.app.domain.activity.dto.request.ActivityFavoriteRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityFavoriteResponse; - -import java.util.List; - -public interface ActivityFavoriteService { - List getFavorites(Long userId); - ActivityFavoriteResponse createFavorite(Long userId, ActivityFavoriteRequest request); - void deleteFavorite(Long userId, Long activityId); -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewService.java deleted file mode 100644 index 235142d..0000000 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewService.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.jjikmeok.app.domain.activity.service; - -import com.jjikmeok.app.domain.activity.dto.request.ActivityReviewRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityReviewResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface ActivityReviewService { - Page getReviews(Long activityId, Pageable pageable); - ActivityReviewResponse createReview(Long userId, Long activityId, ActivityReviewRequest request); - ActivityReviewResponse updateReview(Long userId, Long activityId, Long reviewId, ActivityReviewRequest request); - void deleteReview(Long userId, Long activityId, Long reviewId); -} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImpl.java index 8ff6695..93e8999 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImpl.java @@ -36,6 +36,7 @@ public class ActivityServiceImpl implements ActivityService { private final ActivityRepository activityRepository; private final RegionRepository regionRepository; + private final ActivityTagAutoAttachService activityTagAutoAttachService; @Override public List getActivities(Long regionId, ActivityCategory category, ActivityType type, String keyword) { @@ -104,6 +105,7 @@ public ActivityDetailResponse createActivity(ActivityRequest request) { Activity activity = ActivityConverter.toEntity(request, region); Activity savedActivity = activityRepository.save(activity); + activityTagAutoAttachService.refresh(savedActivity); return ActivityConverter.toDetailResponse(savedActivity); } @@ -126,6 +128,9 @@ public ActivityDetailResponse updateActivity(Long id, ActivityRequest request) { request.thumbnailUrl(), request.sourceUrl(), request.address(), + request.organizer(), + request.contactInfo(), + request.target(), request.startAt(), request.endAt(), request.recruitStartAt(), @@ -138,6 +143,7 @@ public ActivityDetailResponse updateActivity(Long id, ActivityRequest request) { request.approvalStatus(), request.isActive() ); + activityTagAutoAttachService.refresh(activity); return ActivityConverter.toDetailResponse(activity); } diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagAutoAttachService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagAutoAttachService.java new file mode 100644 index 0000000..6209c44 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagAutoAttachService.java @@ -0,0 +1,39 @@ +package com.jjikmeok.app.domain.activity.service; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.activity.enums.PreferenceTag; +import com.jjikmeok.app.domain.tag.entity.Tag; +import com.jjikmeok.app.domain.tag.entity.TagType; +import com.jjikmeok.app.domain.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class ActivityTagAutoAttachService { + + private final ActivityTagSuggestionService suggestionService; + private final TagRepository tagRepository; + + public void refresh(Activity activity) { + if (activity == null) { + return; + } + + List tags = suggestionService.suggest(activity).stream() + .map(this::resolve) + .filter(tag -> tag != null) + .toList(); + + activity.replaceTags(tags); + } + + private Tag resolve(PreferenceTag preferenceTag) { + return tagRepository.findByNameAndType(preferenceTag.getLabel(), TagType.PREFERENCE_TAG) + .orElseGet(() -> tagRepository.save(Tag.create(preferenceTag.getLabel(), TagType.PREFERENCE_TAG))); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagSuggestionService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagSuggestionService.java new file mode 100644 index 0000000..929973a --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityTagSuggestionService.java @@ -0,0 +1,240 @@ +package com.jjikmeok.app.domain.activity.service; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.PreferenceTag; +import com.jjikmeok.app.domain.activity.enums.PreferenceTagGroup; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityTagSuggestionService { + + private final ActivitySyncUtils utils; + + public List suggest(Activity activity) { + if (activity == null) { + return List.of(); + } + + return suggest( + join( + activity.getTitle(), + activity.getDescription(), + activity.getOrganizer(), + activity.getContactInfo(), + activity.getTarget(), + activity.getAddress(), + activity.getSourceUrl() + ), + activity.getCategory(), + activity.getPrice(), + activity.getStartAt(), + activity.getEndAt() + ); + } + + public List suggest( + String text, + ActivityCategory category, + Integer price, + LocalDateTime startAt, + LocalDateTime endAt + ) { + String normalized = utils.cleanText(text); + Map selections = new EnumMap<>(PreferenceTagGroup.class); + + selections.put(PreferenceTagGroup.MOOD, chooseMood(normalized, category)); + selections.put(PreferenceTagGroup.INTENSITY, chooseIntensity(normalized, startAt, endAt)); + selections.put(PreferenceTagGroup.PURPOSE, choosePurpose(normalized, category)); + selections.put(PreferenceTagGroup.DURATION, chooseDuration(normalized, startAt, endAt)); + selections.put(PreferenceTagGroup.SIZE, chooseSize(normalized)); + + List tags = new ArrayList<>(); + for (PreferenceTagGroup group : List.of( + PreferenceTagGroup.MOOD, + PreferenceTagGroup.INTENSITY, + PreferenceTagGroup.PURPOSE, + PreferenceTagGroup.DURATION, + PreferenceTagGroup.SIZE + )) { + PreferenceTag tag = selections.get(group); + if (tag != null && !tags.contains(tag)) { + tags.add(tag); + } + } + + return tags; + } + + private PreferenceTag chooseMood(String text, ActivityCategory category) { + if (contains(text, "차분", "독서", "명상", "조용", "여유", "감성", "쉼", "정리")) { + return PreferenceTag.CALM; + } + if (contains(text, "힐링", "휴식", "치유", "회복", "스트레스")) { + return PreferenceTag.HEALING; + } + if (contains(text, "활기", "러닝", "운동", "댄스", "축제", "파티", "액티브")) { + return PreferenceTag.LIVELY; + } + if (contains(text, "감성", "전시", "사진", "문학", "공연", "아트")) { + return PreferenceTag.EMOTIONAL; + } + if (contains(text, "창의", "만들기", "메이커", "DIY", "그림", "작업", "제작")) { + return PreferenceTag.CREATIVE; + } + if (contains(text, "트렌드", "핫플", "핫", "힙", "인기", "요즘")) { + return PreferenceTag.TRENDY; + } + + return switch (category == null ? ActivityCategory.CULTURE : category) { + case SPORTS, TRAVEL -> PreferenceTag.LIVELY; + case CULTURE, PHOTO_VIDEO -> PreferenceTag.CREATIVE; + case LANGUAGE, HUMANITIES, CAREER -> PreferenceTag.CALM; + case VOLUNTEER -> PreferenceTag.HEALING; + default -> PreferenceTag.CALM; + }; + } + + private PreferenceTag chooseIntensity(String text, LocalDateTime startAt, LocalDateTime endAt) { + long days = durationDays(startAt, endAt); + if (contains(text, "입문", "초보", "처음", "기초", "원데이")) { + return PreferenceTag.BEGINNER; + } + if (contains(text, "가볍게", "라이트", "부담없", "캐주얼") || (days >= 0 && days <= 1)) { + return PreferenceTag.LIGHT; + } + if (contains(text, "몰입", "심화", "집중", "깊이", "실습")) { + return PreferenceTag.IMMERSIVE; + } + if (contains(text, "도전", "고급", "전문", "챌린지")) { + return PreferenceTag.CHALLENGE; + } + if (days > 31) { + return PreferenceTag.IMMERSIVE; + } + return PreferenceTag.LIGHT; + } + + private PreferenceTag choosePurpose(String text, ActivityCategory category) { + if (contains(text, "휴식", "회복", "힐링", "명상", "쉼")) { + return PreferenceTag.REST; + } + if (contains(text, "취미", "원데이", "클래스", "만들기", "체험")) { + return PreferenceTag.HOBBY; + } + if (contains(text, "배움", "강의", "교육", "학습", "수업", "스터디")) { + return PreferenceTag.LEARNING; + } + if (contains(text, "성장", "커리어", "이직", "직무", "취업", "역량")) { + return PreferenceTag.GROWTH; + } + if (contains(text, "모임", "네트워킹", "친목", "소통", "커뮤니티")) { + return PreferenceTag.SOCIAL; + } + if (contains(text, "체험", "탐방", "경험", "방문", "투어")) { + return PreferenceTag.EXPERIENCE; + } + + return switch (category == null ? ActivityCategory.CULTURE : category) { + case VOLUNTEER, CAREER, LANGUAGE -> PreferenceTag.GROWTH; + case SPORTS, TRAVEL -> PreferenceTag.EXPERIENCE; + case HUMANITIES -> PreferenceTag.LEARNING; + default -> PreferenceTag.HOBBY; + }; + } + + private PreferenceTag chooseDuration(String text, LocalDateTime startAt, LocalDateTime endAt) { + if (startAt != null && endAt != null) { + long days = ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()) + 1; + if (days <= 1) { + return PreferenceTag.ONE_DAY; + } + if (days <= 3) { + return PreferenceTag.THREE_DAYS; + } + if (days <= 7) { + return PreferenceTag.ONE_WEEK; + } + if (days <= 31) { + return PreferenceTag.ONE_MONTH; + } + if (days <= 93) { + return PreferenceTag.THREE_MONTHS; + } + if (days <= 366) { + return PreferenceTag.OVER_SIX_MONTHS; + } + return PreferenceTag.OVER_ONE_YEAR; + } + + if (contains(text, "원데이", "하루", "당일")) { + return PreferenceTag.ONE_DAY; + } + if (contains(text, "3일", "삼일", "단기")) { + return PreferenceTag.THREE_DAYS; + } + if (contains(text, "1주", "일주일")) { + return PreferenceTag.ONE_WEEK; + } + if (contains(text, "1개월", "한달", "한 달")) { + return PreferenceTag.ONE_MONTH; + } + if (contains(text, "3개월")) { + return PreferenceTag.THREE_MONTHS; + } + return PreferenceTag.ONE_MONTH; + } + + private PreferenceTag chooseSize(String text) { + if (contains(text, "대규모", "대형", "수십명", "수백명", "페스티벌", "행사장")) { + return PreferenceTag.LARGE; + } + if (contains(text, "소규모", "소그룹", "1:1", "개인", "클래스", "소수")) { + return PreferenceTag.SMALL; + } + return PreferenceTag.SMALL; + } + + private boolean contains(String text, String... keywords) { + String value = text == null ? "" : text; + for (String keyword : keywords) { + if (keyword != null && !keyword.isBlank() && value.contains(keyword)) { + return true; + } + } + return false; + } + + private long durationDays(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt == null || endAt == null) { + return -1; + } + return ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()); + } + + private String join(String... values) { + StringBuilder builder = new StringBuilder(); + for (String value : values) { + if (value == null || value.isBlank()) { + continue; + } + if (builder.length() > 0) { + builder.append(' '); + } + builder.append(value); + } + return builder.length() == 0 ? null : builder.toString(); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/AdminActivityIngestionService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/AdminActivityIngestionService.java new file mode 100644 index 0000000..9e7efe8 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/activity/service/AdminActivityIngestionService.java @@ -0,0 +1,56 @@ +package com.jjikmeok.app.domain.activity.service; + +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.privateactivity.collector.DiscoveryCollectorService; +import com.jjikmeok.app.domain.activity.privateactivity.dto.response.DiscoverySheetRowDto; +import com.jjikmeok.app.domain.activity.privateactivity.publish.DiscoveryPublishService; +import com.jjikmeok.app.domain.activity.publicactivity.dto.ActivitySyncResponse; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncService; +import com.jjikmeok.app.global.common.exception.CustomException; +import com.jjikmeok.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminActivityIngestionService { + + private final ActivitySyncService activitySyncService; + private final DiscoveryCollectorService discoveryCollectorService; + private final DiscoveryPublishService discoveryPublishService; + + @Value("${app.discovery.scheduler.keywords-per-run:${app.discovery.scheduler.keyword-limit:10}}") + private int defaultKeywordLimit; + + @Value("${app.discovery.search.results-per-keyword:${app.discovery.search.result-limit:10}}") + private int defaultResultLimit; + + public void syncAllPublicSources() { + activitySyncService.syncAllSources(); + } + + public ActivitySyncResponse syncPublicSource(SourceType sourceType, Integer maxPages) { + validatePublicSource(sourceType); + return activitySyncService.sync(sourceType, null, maxPages); + } + + public List collectDiscoveryActivities(Integer keywordLimit, Integer resultLimit) { + return discoveryCollectorService.runAll( + keywordLimit == null ? defaultKeywordLimit : keywordLimit, + resultLimit == null ? defaultResultLimit : resultLimit + ); + } + + public int publishDiscoveryActivities() { + return discoveryPublishService.publishReadyRows(); + } + + private void validatePublicSource(SourceType sourceType) { + if (sourceType == null || !sourceType.isPublicApiSource()) { + throw new CustomException(ErrorCode.ACTIVITY_SYNC_UNSUPPORTED_SOURCE); + } + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityService.java b/src/main/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityService.java index 70bd29c..0c512e1 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityService.java +++ b/src/main/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityService.java @@ -11,7 +11,7 @@ import com.jjikmeok.app.domain.activity.enums.PreferenceTag; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; -import com.jjikmeok.app.domain.sync.service.CategoryClassifier; +import com.jjikmeok.app.domain.activity.publicactivity.service.CategoryClassifier; import com.jjikmeok.app.domain.region.entity.Region; import com.jjikmeok.app.domain.region.repository.RegionRepository; import com.jjikmeok.app.global.common.exception.CustomException; @@ -58,6 +58,8 @@ public class UrlManualActivityService { private final RegionRepository regionRepository; private final CategoryClassifier classifier; private final ObjectMapper objectMapper; + private final ActivityTagSuggestionService activityTagSuggestionService; + private final ActivityTagAutoAttachService activityTagAutoAttachService; private final RestClient restClient = RestClient.create(); @Value("${app.activity-sync.default-region-id:1}") @@ -112,7 +114,13 @@ Preview previewFromHtml(String sourceUrl, String html) { price, classifier.classifyCategory(SourceType.URL_MANUAL, text), classifier.classifyType(SourceType.URL_MANUAL, text, startAt, endAt), - suggestTags(text, price, startAt, endAt) + activityTagSuggestionService.suggest( + text, + classifier.classifyCategory(SourceType.URL_MANUAL, text), + price, + startAt, + endAt + ) ); } @@ -126,7 +134,7 @@ public ActivityDetailResponse saveManual(ManualCommand command) { String text = title + " " + description + " " + safe(command.address()); ActivityCategory category = command.category() != null ? command.category() : classifier.classifyCategory(SourceType.URL_MANUAL, text); ActivityType activityType = command.activityType() != null ? command.activityType() : classifier.classifyType(SourceType.URL_MANUAL, text, command.startAt(), command.endAt()); - String externalId = hash(sourceUrl); + String externalId = createManualExternalId(sourceUrl); LocalDateTime recruitEndAt = command.recruitEndAt() != null ? command.recruitEndAt() : command.endAt(); Activity existing = activityRepository.findDuplicate(SourceType.URL_MANUAL, externalId, sourceUrl, title, command.startAt(), command.address()).orElse(null); if (existing == null) { @@ -154,11 +162,13 @@ public ActivityDetailResponse saveManual(ManualCommand command) { .build()); } else { existing.update(region, title, description, command.thumbnailUrl(), sourceUrl, command.address(), + command.organizer(), command.contactInfo(), command.target(), command.startAt(), command.endAt(), command.recruitStartAt(), recruitEndAt != null ? recruitEndAt : LocalDateTime.now().plusMonths(1), command.price(), activityType, category, SourceType.URL_MANUAL, externalId, ApprovalStatus.PENDING, false); existing.updateExtra(command.organizer(), command.contactInfo(), command.target()); } + activityTagAutoAttachService.refresh(existing); return ActivityConverter.toDetailResponse(existing); } @@ -565,7 +575,25 @@ private String repairMojibake(String value) { } private boolean looksMojibake(String value) { - return value.matches(".*[ÃÂìíëê][\\u0080-\\u00ff]?.*"); + if (value == null || value.isBlank()) { + return false; + } + + for (int i = 0; i < value.length() - 1; i++) { + char current = value.charAt(i); + char next = value.charAt(i + 1); + boolean marker = + current == '\u00C3' + || current == '\u00C2' + || current == '\u00EC' + || current == '\u00ED' + || current == '\u00EB' + || current == '\u00EA'; + if (marker && next >= '\u0080' && next <= '\u00FF') { + return true; + } + } + return false; } private int hangulCount(String value) { @@ -604,7 +632,11 @@ private String validTitle(String value) { return cleaned; } - private String hash(String value) { + private String createManualExternalId(String normalizedSourceUrl) { + return hashExternalIdSeed(normalizedSourceUrl); + } + + private String hashExternalIdSeed(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); @@ -632,8 +664,6 @@ private String safe(String value) { private List suggestTags(String text, Integer price, LocalDateTime startAt, LocalDateTime endAt) { List tags = new ArrayList<>(); - if ((price != null && price == 0) || contains(text, "무료")) add(tags, PreferenceTag.FREE); - if ((price != null && price > 0) || contains(text, "유료")) add(tags, PreferenceTag.PAID); if (contains(text, "차분", "독서", "명상")) add(tags, PreferenceTag.CALM); if (contains(text, "활기", "러닝", "운동", "댄스")) add(tags, PreferenceTag.LIVELY); if (contains(text, "힐링", "휴식")) add(tags, PreferenceTag.HEALING); diff --git a/src/main/java/com/jjikmeok/app/domain/advertisement/controller/AdvertisementController.java b/src/main/java/com/jjikmeok/app/domain/advertisement/controller/AdvertisementController.java index a77f0f0..6f6ad16 100644 --- a/src/main/java/com/jjikmeok/app/domain/advertisement/controller/AdvertisementController.java +++ b/src/main/java/com/jjikmeok/app/domain/advertisement/controller/AdvertisementController.java @@ -52,7 +52,7 @@ public ApiResponse getAdvertisement( @PreAuthorize("hasRole('ADMIN')") public ApiResponse createAdvertisement( @RequestBody @Valid AdvertisementRequest request) { - return ApiResponse.success("광고 생성 성공", advertisementService.createAdvertisement(request)); + return ApiResponse.created("광고 생성 성공", advertisementService.createAdvertisement(request)); } @Operation(summary = "광고 수정") diff --git a/src/main/java/com/jjikmeok/app/domain/ai/dto/DiscoveryAnalysisDto.java b/src/main/java/com/jjikmeok/app/domain/ai/dto/DiscoveryAnalysisDto.java new file mode 100644 index 0000000..206ae91 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/ai/dto/DiscoveryAnalysisDto.java @@ -0,0 +1,42 @@ +package com.jjikmeok.app.domain.ai.dto; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryDuration; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryGroupSize; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryIntensity; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryMood; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryPurpose; +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; + +import java.time.LocalDateTime; + +public record DiscoveryAnalysisDto( + String keyword, + String sourceName, + String title, + String sourceUrl, + String thumbnailUrl, + String description, + String organizer, + String contactInfo, + String target, + String address, + LocalDateTime startAt, + LocalDateTime endAt, + LocalDateTime recruitStartAt, + LocalDateTime recruitEndAt, + Integer price, + ActivityCategory category, + ActivityType activityType, + DiscoveryMood moodTag1, + DiscoveryMood moodTag2, + DiscoveryIntensity intensity, + DiscoveryPurpose purpose, + DiscoveryDuration duration, + DiscoveryGroupSize groupSize, + double confidenceScore, + String searchSnippet, + ExtractionMode extractionMode +) { +} diff --git a/src/main/java/com/jjikmeok/app/domain/ai/dto/ExtractedActivityDto.java b/src/main/java/com/jjikmeok/app/domain/ai/dto/ExtractedActivityDto.java index 39db129..8895c45 100644 --- a/src/main/java/com/jjikmeok/app/domain/ai/dto/ExtractedActivityDto.java +++ b/src/main/java/com/jjikmeok/app/domain/ai/dto/ExtractedActivityDto.java @@ -3,6 +3,16 @@ import java.time.LocalDateTime; public record ExtractedActivityDto( + String title, + String address, + String category, + String activityType, + String moodTag1, + String moodTag2, + String intensity, + String purpose, + String duration, + String groupSize, LocalDateTime recruitStartAt, LocalDateTime recruitEndAt, LocalDateTime startAt, @@ -12,4 +22,5 @@ public record ExtractedActivityDto( String target, String contactInfo, String organizer -) {} \ No newline at end of file +) { +} diff --git a/src/main/java/com/jjikmeok/app/domain/ai/service/AiActivityParser.java b/src/main/java/com/jjikmeok/app/domain/ai/service/AiActivityParser.java index a9bf00c..3c492e8 100644 --- a/src/main/java/com/jjikmeok/app/domain/ai/service/AiActivityParser.java +++ b/src/main/java/com/jjikmeok/app/domain/ai/service/AiActivityParser.java @@ -22,11 +22,19 @@ public AiActivityParser(ChatClient.Builder chatClientBuilder) { } public ExtractedActivityDto parseFallback(String coreContext, SourceType sourceType) { + return parse(coreContext, sourceType, "fallback"); + } + + public ExtractedActivityDto parseDiscovery(String coreContext) { + return parse(coreContext, null, "discovery"); + } + + private ExtractedActivityDto parse(String coreContext, SourceType sourceType, String mode) { try { return chatClient.prompt() - .system(systemPrompt(sourceType)) + .system(systemPrompt(sourceType, mode)) .user(u -> u.text(""" - [분석 대상 원문] + [분석 대상 텍스트] {text} [출력 스키마] @@ -37,146 +45,87 @@ public ExtractedActivityDto parseFallback(String coreContext, SourceType sourceT .call() .entity(outputConverter); } catch (Exception e) { - log.warn("⚠️ AI 보완 파싱 실패. sourceType={}, reason={}", sourceType, e.getMessage()); - return new ExtractedActivityDto(null, null, null, null, null, null, null, null, null); + log.warn("[AI] parse failed. mode={}, sourceType={}, reason={}", mode, sourceType, e.getMessage()); + return empty(); } } - private String systemPrompt(SourceType sourceType) { + private ExtractedActivityDto empty() { + return new ExtractedActivityDto( + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null + ); + } + + private String systemPrompt(SourceType sourceType, String mode) { return """ - 당신은 외부 오픈 API 원문과 source_url 상세 페이지 본문에서 Activity 엔티티 보완 필드만 추출하는 데이터 정규화 엔진입니다. - - 반드시 JSON 객체 하나만 반환하세요. - 설명, 마크다운, 코드블록, 부가 문장은 절대 반환하지 마세요. - - [공통 절대 규칙] - - 원문에 명확히 존재하는 값만 추출합니다. - - 추측하지 않습니다. - - 모르는 값은 null입니다. - - 2099년, 2999년, 9999년 날짜는 null입니다. - - fallback 문구는 절대 반환하지 않습니다. - - contactInfo는 전화번호 또는 이메일만 반환합니다. - - "고객센터", "문의처", "담당자" 같은 일반 단어만 있으면 contactInfo는 null입니다. - - target은 관람연령, 신청대상, 이용대상, 모집대상, 봉사대상, 참석자 조건만 반환합니다. - - organizer는 주최, 주관, 운영기관, 모집기관명만 반환합니다. - - description은 사용자에게 보여줄 자연스러운 1~3문장 요약입니다. - - description에 URL, 신청 바로가기, HTML 태그, 메뉴명, 공유문구, 원본 XML/JSON을 넣지 않습니다. - - price는 숫자만 반환합니다. - - 무료, 전석무료, 참가비 없음이면 price=0입니다. - - 유료인데 금액이 불명확하면 price=null입니다. - - 비용 정보가 없으면 price=null입니다. - - 날짜만 있으면 00:00:00으로 반환합니다. - - 월/일만 있으면 2026년 기준입니다. - - 모집기간과 실제 활동기간을 혼동하지 마세요. - - [날짜 필드] - - recruitStartAt: 신청/모집/접수 시작일 - - recruitEndAt: 신청/모집/접수 마감일 - - startAt: 실제 행사/공연/전시/교육/봉사 시작일 - - endAt: 실제 행사/공연/전시/교육/봉사 종료일 - - """ + sourcePrompt(sourceType); + You extract missing activity fields. + Return JSON only. + Do not invent values. Use null when unsure. + Use only the allowed enum names for these fields: + - category: SPORTS, CULTURE, CRAFT, COOKING, PHOTO_VIDEO, HUMANITIES, TRAVEL, LANGUAGE, VOLUNTEER, CAREER + - activityType: PROGRAM, ONE_DAY, EVENT, CLUB + - moodTag1/moodTag2: CALM, HEALING, LIVELY, EMOTIONAL, CREATIVE, TRENDY + - intensity: BEGINNER, LIGHT, IMMERSIVE, CHALLENGE + - purpose: REST, HOBBY, LEARNING, GROWTH + - duration: SHORT_TERM, ONE_MONTH, SIX_MONTHS, OVER_ONE_YEAR + - groupSize: SMALL, LARGE + + Fields: + title, address, category, activityType, moodTag1, moodTag2, intensity, purpose, duration, groupSize, + recruitStartAt, recruitEndAt, startAt, endAt, price, description, target, contactInfo, organizer + + """ + sourcePrompt(sourceType, mode); } - private String sourcePrompt(SourceType sourceType) { - if (sourceType == null) return ""; + private String sourcePrompt(SourceType sourceType, String mode) { + if (sourceType == null && !"fallback".equals(mode)) { + return ""; + } - return switch (sourceType) { - case VOLUNTEER_1365 -> """ - - [1365 봉사 API 전용 규칙] - - 봉사기간은 startAt/endAt입니다. - - 모집기간은 recruitStartAt/recruitEndAt입니다. - - 봉사대상은 target입니다. 예: 아동·청소년, 노인, 장애인, 기타, 환경, 동물. - - 모집기관은 organizer입니다. - - 봉사장소 또는 지도 주소는 address용 정보이지만 이 DTO에는 address 필드가 없으므로 description에 섞지 않습니다. - - contactInfo는 담당자 전화번호 또는 이메일만 반환합니다. 1522-3658은 제외합니다. - - description은 상세정보의 봉사내용을 중심으로 1~2문장으로 요약합니다. - - "활동구분", "첨부파일", "SNS 공유", "목록", "신청하기"는 무시합니다. - """; + if (sourceType == null) { + return ""; + } + return switch (sourceType) { case KOPIS -> """ - - [KOPIS 전용 규칙] - - 공연기간은 startAt/endAt입니다. - - 관람연령은 target입니다. - - 티켓가격은 price입니다. - - 주최·주관은 organizer입니다. - - 문의 전화번호 또는 이메일은 contactInfo입니다. - - 공연 소개, 출연진, 제작진 정보를 description으로 요약합니다. - - 공연장 주소는 description에 섞지 않습니다. - - recruitStartAt/recruitEndAt은 대부분 null입니다. - """; - - case YOUTH_CONTENT -> """ - - [YOUTH_CONTENT 전용 규칙] - - 모집기간은 recruitStartAt/recruitEndAt입니다. - - 일경험 기간, 활동기간, 교육기간, 프로젝트 기간은 startAt/endAt입니다. - - 주관기관 또는 운영기관은 organizer입니다. - - 지역이 있으면 실제 활동 지역 판단에 활용합니다. - - target은 청년, 미취업 청년, 만 19~39세, 구직자 등 명확한 대상만 반환합니다. - - contactInfo는 문의처의 전화번호/이메일만 반환합니다. - - price는 무료가 명확하면 0, 비용 정보가 없으면 null입니다. - - 설문 문항, "내 답변", "구직상태", "알게 된 경로"는 무시합니다. - - docs.google 폼 본문은 일시/장소/참석자/신청기간/문의 필드를 각각 분리해서 판단합니다. - - miniintern 본문은 접수기간, 활동기간, 진행장소, 참가비용, 문의를 분리해서 판단합니다. + [KOPIS] + - Use performance period for startAt/endAt. + - organizer is the organizer or host. + - contactInfo is inquiry phone or email. + - address is venue/location if present. """; - case EXHIBITION -> """ - - [EXHIBITION 전용 규칙] - - PERIOD 또는 EVENT_PERIOD는 startAt/endAt입니다. - - CONTACT_POINT는 contactInfo입니다. - - CONTRIBUTOR 또는 CNTC_INSTT_NM은 organizer입니다. - - AUDIENCE가 A이면 "전체 관람가" 또는 "전체관람"으로 판단합니다. - - CHARGE가 무료 또는 0이면 price=0입니다. - - CHARGE가 01, 02 같은 코드값이면 price=null입니다. - - DESCRIPTION은 전시 주제와 주요 관람 내용을 중심으로 요약합니다. - - URL 상세 페이지에 요금정보, 입장연령, 도로명주소, 연락처가 있으면 그 값을 우선합니다. + [EXHIBITION] + - Use PERIOD / EVENT_PERIOD for startAt/endAt. + - contactInfo from CONTACT_POINT. + - organizer from CONTRIBUTOR. + - address from venue/location if present. """; - case SEOUL_CULTURE -> """ - - [SEOUL_CULTURE 전용 규칙] - - DATE, STRTDATE, END_DATE는 startAt/endAt입니다. - - PRO_TIME은 시간 정보입니다. - - ORG_NAME은 organizer입니다. - - USE_TRGT는 target입니다. - - USE_FEE는 price입니다. - - INQUIRY는 contactInfo입니다. - - ETC_DESC, PROGRAM, 상세 URL 본문 소개글은 description입니다. + [SEOUL_CULTURE] + - Use DATE / END_DATE for startAt/endAt. + - organizer from ORG_NAME. + - contactInfo from INQUIRY. + - target from USE_TRGT when available. """; - case SEOUL_RESERVATION -> """ - - [SEOUL_RESERVATION 전용 규칙] - - RCPTBGNDT/RCPTENDDT는 recruitStartAt/recruitEndAt입니다. - - SVCOPNBGNDT/SVCOPNENDDT는 startAt/endAt입니다. - - USETGTINFO, USE_TRGT는 target입니다. - - TELNO는 contactInfo입니다. - - SVCCHARGENM, PAYATNM은 price입니다. - - DTLCONT, 상세 URL 본문은 description입니다. - """; - - case TOUR_API -> """ - - [TOUR_API 전용 규칙] - - eventstartdate/eventenddate는 startAt/endAt입니다. - - overview, intro, content는 description입니다. - - tel은 contactInfo입니다. - - recruitStartAt/recruitEndAt은 대부분 null입니다. - - 비용 정보가 없으면 price=null입니다. + [SEOUL_RESERVATION] + - Use RCPTBGNDT / RCPTENDDT for recruitStartAt/recruitEndAt. + - Use SVCOPNBGNDT / SVCOPNENDDT for startAt/endAt. + - contactInfo from TELNO. + - target from USETGTINFO or USE_TRGT. """; - default -> ""; }; } private String compact(String value) { - if (value == null || value.length() <= MAX_CONTEXT_CHARS) return value; + if (value == null || value.length() <= MAX_CONTEXT_CHARS) { + return value; + } int head = MAX_CONTEXT_CHARS / 2; int tail = MAX_CONTEXT_CHARS - head; - return value.substring(0, head) + "\n...[중간 생략]...\n" + value.substring(value.length() - tail); + return value.substring(0, head) + "\n...[truncated]...\n" + value.substring(value.length() - tail); } -} \ No newline at end of file +} diff --git a/src/main/java/com/jjikmeok/app/domain/ai/service/DiscoveryAiAnalysisService.java b/src/main/java/com/jjikmeok/app/domain/ai/service/DiscoveryAiAnalysisService.java new file mode 100644 index 0000000..c69a4ce --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/ai/service/DiscoveryAiAnalysisService.java @@ -0,0 +1,419 @@ +package com.jjikmeok.app.domain.ai.service; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.ai.dto.DiscoveryAnalysisDto; +import com.jjikmeok.app.domain.ai.dto.ExtractedActivityDto; +import com.jjikmeok.app.domain.activity.privateactivity.dto.DiscoveryCandidateDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryDuration; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryGroupSize; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryIntensity; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryMood; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoveryPurpose; +import com.jjikmeok.app.domain.activity.publicactivity.service.ActivitySyncUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscoveryAiAnalysisService { + + private final AiActivityParser aiActivityParser; + private final ActivitySyncUtils utils; + + public DiscoveryAnalysisDto analyze(DiscoveryCandidateDto candidate) { + if (candidate == null) { + return null; + } + + ExtractedActivityDto ai = aiActivityParser.parseDiscovery(context(candidate)); + DiscoveryCandidateDto merged = merge(candidate, ai); + + String text = utils.cleanText(join( + merged.keyword(), + merged.title(), + merged.description(), + merged.organizer(), + merged.contactInfo(), + merged.target(), + merged.address(), + merged.pageText(), + candidate.searchResult() == null ? null : candidate.searchResult().snippet() + )); + + ActivityCategory category = firstNonNull( + classifyCategory(text), + parseEnum(ai.category(), ActivityCategory.class) + ); + ActivityType activityType = firstNonNull( + classifyType(text, merged.startAt(), merged.endAt()), + parseEnum(ai.activityType(), ActivityType.class) + ); + + List moods = classifyMoods(text); + if (moods.size() < 2) { + addMoodIfMissing(moods, parseEnum(ai.moodTag1(), DiscoveryMood.class)); + addMoodIfMissing(moods, parseEnum(ai.moodTag2(), DiscoveryMood.class)); + } + + DiscoveryIntensity intensity = firstNonNull( + classifyIntensity(text, merged.startAt(), merged.endAt()), + parseEnum(ai.intensity(), DiscoveryIntensity.class) + ); + DiscoveryPurpose purpose = firstNonNull( + classifyPurpose(text), + parseEnum(ai.purpose(), DiscoveryPurpose.class) + ); + DiscoveryDuration duration = firstNonNull( + classifyDuration(text, merged.startAt(), merged.endAt()), + parseEnum(ai.duration(), DiscoveryDuration.class) + ); + DiscoveryGroupSize groupSize = firstNonNull( + classifyGroupSize(text), + parseEnum(ai.groupSize(), DiscoveryGroupSize.class) + ); + + return new DiscoveryAnalysisDto( + merged.keyword(), + merged.searchResult() == null || merged.searchResult().sourceChannel() == null + ? "WEBSITE" + : merged.searchResult().sourceChannel().name(), + merged.title(), + merged.sourceUrl(), + merged.thumbnailUrl(), + merged.description(), + merged.organizer(), + merged.contactInfo(), + merged.target(), + merged.address(), + merged.startAt(), + merged.endAt(), + merged.recruitStartAt(), + merged.recruitEndAt(), + merged.price(), + category, + activityType, + moods.size() > 0 ? moods.get(0) : parseEnum(ai.moodTag1(), DiscoveryMood.class), + moods.size() > 1 ? moods.get(1) : parseEnum(ai.moodTag2(), DiscoveryMood.class), + intensity, + purpose, + duration, + groupSize, + confidenceScore(merged, ai != null), + candidate.searchResult() == null ? null : candidate.searchResult().snippet(), + merged.extractionMode() + ); + } + + private DiscoveryCandidateDto merge(DiscoveryCandidateDto candidate, ExtractedActivityDto ai) { + if (ai == null) { + return candidate; + } + + return new DiscoveryCandidateDto( + candidate.keyword(), + candidate.searchResult(), + first(candidate.title(), candidate.searchResult() == null ? null : candidate.searchResult().title(), ai.title()), + candidate.sourceUrl(), + candidate.thumbnailUrl(), + firstText(ai.description(), candidate.description()), + firstText(ai.organizer(), candidate.organizer()), + firstText(ai.contactInfo(), candidate.contactInfo()), + firstText(ai.target(), candidate.target()), + firstText(candidate.address(), ai.address()), + firstDate(candidate.startAt(), ai.startAt()), + firstDate(candidate.endAt(), ai.endAt()), + firstDate(candidate.recruitStartAt(), ai.recruitStartAt()), + firstDate(candidate.recruitEndAt(), ai.recruitEndAt()), + firstPrice(candidate.price(), ai.price()), + candidate.extractionMode(), + candidate.confidenceScore(), + candidate.pageText() + ); + } + + private ActivityCategory classifyCategory(String text) { + if (contains(text, "운동", "스포츠", "액티비티", "러닝", "댄스", "요가")) { + return ActivityCategory.SPORTS; + } + if (contains(text, "문화", "예술", "공연", "전시", "음악", "연극", "콘서트")) { + return ActivityCategory.CULTURE; + } + if (contains(text, "공예", "만들기", "DIY", "메이커", "제작")) { + return ActivityCategory.CRAFT; + } + if (contains(text, "요리", "베이킹", "쿠킹", "조리")) { + return ActivityCategory.COOKING; + } + if (contains(text, "사진", "영상", "촬영", "편집", "미디어")) { + return ActivityCategory.PHOTO_VIDEO; + } + if (contains(text, "책", "글", "독서", "문학", "강연")) { + return ActivityCategory.HUMANITIES; + } + if (contains(text, "여행", "탐방", "투어", "답사", "산책")) { + return ActivityCategory.TRAVEL; + } + if (contains(text, "언어", "영어", "일본어", "중국어", "외국어", "해외")) { + return ActivityCategory.LANGUAGE; + } + if (contains(text, "봉사", "기부", "나눔", "재능기부")) { + return ActivityCategory.VOLUNTEER; + } + if (contains(text, "성장", "커리어", "취업", "이직", "직무", "네트워킹")) { + return ActivityCategory.CAREER; + } + return null; + } + + private ActivityType classifyType(String text, LocalDateTime startAt, LocalDateTime endAt) { + if (contains(text, "클럽", "모임", "스터디", "동아리")) { + return ActivityType.CLUB; + } + if (contains(text, "원데이", "하루", "당일")) { + return ActivityType.ONE_DAY; + } + if (contains(text, "프로그램", "강좌", "교육", "수업", "클래스")) { + return ActivityType.PROGRAM; + } + if (contains(text, "공연", "행사", "전시", "축제", "모집")) { + return ActivityType.EVENT; + } + if (startAt != null && endAt != null && ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()) <= 0) { + return ActivityType.ONE_DAY; + } + return null; + } + + private List classifyMoods(String text) { + Set moods = new LinkedHashSet<>(); + addMood(moods, text, DiscoveryMood.CALM, "차분", "조용", "여유", "쉼", "독서", "명상", "산책"); + addMood(moods, text, DiscoveryMood.HEALING, "힐링", "휴식", "치유", "회복"); + addMood(moods, text, DiscoveryMood.LIVELY, "활기", "운동", "댄스", "축제", "액티비티", "러닝"); + addMood(moods, text, DiscoveryMood.EMOTIONAL, "감성", "전시", "사진", "문학", "공연"); + addMood(moods, text, DiscoveryMood.CREATIVE, "창의", "만들기", "메이커", "DIY", "작업", "제작"); + addMood(moods, text, DiscoveryMood.TRENDY, "트렌드", "핫", "인기", "힙"); + return new ArrayList<>(moods); + } + + private DiscoveryIntensity classifyIntensity(String text, LocalDateTime startAt, LocalDateTime endAt) { + long days = durationDays(startAt, endAt); + if (contains(text, "입문", "초보", "처음", "기초")) { + return DiscoveryIntensity.BEGINNER; + } + if (contains(text, "가볍게", "부담없", "라이트") || (days >= 0 && days <= 1)) { + return DiscoveryIntensity.LIGHT; + } + if (contains(text, "몰입", "심화", "집중", "깊이")) { + return DiscoveryIntensity.IMMERSIVE; + } + if (contains(text, "도전", "고급", "챌린지")) { + return DiscoveryIntensity.CHALLENGE; + } + return null; + } + + private DiscoveryPurpose classifyPurpose(String text) { + if (contains(text, "휴식", "힐링", "명상", "쉼")) { + return DiscoveryPurpose.REST; + } + if (contains(text, "취미", "클래스", "원데이", "체험", "만들기")) { + return DiscoveryPurpose.HOBBY; + } + if (contains(text, "배움", "교육", "강의", "강좌", "스터디")) { + return DiscoveryPurpose.LEARNING; + } + if (contains(text, "성장", "커리어", "취업", "이직", "직무")) { + return DiscoveryPurpose.GROWTH; + } + return null; + } + + private DiscoveryDuration classifyDuration(String text, LocalDateTime startAt, LocalDateTime endAt) { + long days = durationDays(startAt, endAt); + if (days >= 0 && days <= 7) { + return DiscoveryDuration.SHORT_TERM; + } + if (days > 7 && days <= 31) { + return DiscoveryDuration.ONE_MONTH; + } + if (days > 31 && days <= 183) { + return DiscoveryDuration.SIX_MONTHS; + } + if (days > 183) { + return DiscoveryDuration.OVER_ONE_YEAR; + } + if (contains(text, "단기", "원데이", "하루")) { + return DiscoveryDuration.SHORT_TERM; + } + if (contains(text, "1개월", "한달", "한 달")) { + return DiscoveryDuration.ONE_MONTH; + } + if (contains(text, "6개월", "반년")) { + return DiscoveryDuration.SIX_MONTHS; + } + if (contains(text, "1년", "장기")) { + return DiscoveryDuration.OVER_ONE_YEAR; + } + return null; + } + + private DiscoveryGroupSize classifyGroupSize(String text) { + if (contains(text, "대규모", "수백명", "수천명", "페스티벌", "행사장")) { + return DiscoveryGroupSize.LARGE; + } + if (contains(text, "소규모", "소수", "개인", "1:1", "클래스")) { + return DiscoveryGroupSize.SMALL; + } + return null; + } + + private double confidenceScore(DiscoveryCandidateDto candidate, boolean aiUsed) { + double score = candidate.confidenceScore(); + if (!utils.isBlank(candidate.title())) score += 10; + if (!utils.isBlank(candidate.description())) score += 10; + if (!utils.isBlank(candidate.organizer())) score += 10; + if (!utils.isBlank(candidate.contactInfo())) score += 10; + if (!utils.isBlank(candidate.target())) score += 5; + if (!utils.isBlank(candidate.address())) score += 5; + if (candidate.startAt() != null) score += 10; + if (candidate.endAt() != null) score += 10; + if (candidate.recruitStartAt() != null) score += 5; + if (candidate.recruitEndAt() != null) score += 5; + if (candidate.price() != null) score += 5; + if (aiUsed) score += 5; + return Math.min(100, score); + } + + private long durationDays(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt == null || endAt == null) { + return -1; + } + return Math.max(0, ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()) + 1); + } + + private String context(DiscoveryCandidateDto candidate) { + return """ + [keyword] + %s + + [title] + %s + + [url] + %s + + [snippet] + %s + + [organizer] + %s + + [contact] + %s + + [target] + %s + + [address] + %s + + [page] + %s + """.formatted( + candidate.keyword(), + candidate.title(), + candidate.sourceUrl(), + candidate.searchResult() == null ? null : candidate.searchResult().snippet(), + candidate.organizer(), + candidate.contactInfo(), + candidate.target(), + candidate.address(), + candidate.pageText() + ); + } + + private boolean contains(String text, String... keywords) { + String value = text == null ? "" : text; + for (String keyword : keywords) { + if (keyword != null && !keyword.isBlank() && value.contains(keyword)) { + return true; + } + } + return false; + } + + private void addMood(Set moods, String text, DiscoveryMood mood, String... keywords) { + if (contains(text, keywords)) { + moods.add(mood); + } + } + + private void addMoodIfMissing(List moods, DiscoveryMood mood) { + if (mood != null && !moods.contains(mood) && moods.size() < 2) { + moods.add(mood); + } + } + + private String join(String... values) { + StringBuilder builder = new StringBuilder(); + for (String value : values) { + if (utils.isBlank(value)) { + continue; + } + if (builder.length() > 0) { + builder.append(' '); + } + builder.append(value); + } + return builder.toString(); + } + + private String first(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private String firstText(String first, String second) { + return first != null && !first.isBlank() ? first : second; + } + + private LocalDateTime firstDate(LocalDateTime existing, LocalDateTime incoming) { + return existing != null ? existing : incoming; + } + + private Integer firstPrice(Integer existing, Integer incoming) { + return existing != null ? existing : incoming; + } + + private > E parseEnum(String value, Class type) { + if (value == null || value.isBlank()) { + return null; + } + + String normalized = value.trim(); + for (E constant : type.getEnumConstants()) { + if (constant.name().equalsIgnoreCase(normalized)) { + return constant; + } + } + return null; + } + + private T firstNonNull(T left, T right) { + return left != null ? left : right; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/client/google/GoogleOAuthClient.java b/src/main/java/com/jjikmeok/app/domain/auth/client/google/GoogleOAuthClient.java index 1789c0d..efb5396 100644 --- a/src/main/java/com/jjikmeok/app/domain/auth/client/google/GoogleOAuthClient.java +++ b/src/main/java/com/jjikmeok/app/domain/auth/client/google/GoogleOAuthClient.java @@ -39,7 +39,7 @@ public GoogleOAuthClient( this.redirectUri = redirectUri; } - public String getAccessToken(@NotBlank final String code) { + public GoogleOAuthRes.TokenResponse getToken(@NotBlank final String code) { try { final GoogleOAuthRes.TokenResponse tokenResponse = restClient.post() .uri(GOOGLE_TOKEN_URL) @@ -49,7 +49,7 @@ public String getAccessToken(@NotBlank final String code) { .body(GoogleOAuthRes.TokenResponse.class); validateTokenResponse(tokenResponse); - return tokenResponse.accessToken(); + return tokenResponse; } catch (final RestClientException e) { log.error("구글 액세스 토큰 요청에 실패했습니다.", e); throw new CustomException(ErrorCode.AUTH_INVALID_SOCIAL_ACCESS_TOKEN); diff --git a/src/main/java/com/jjikmeok/app/domain/auth/client/kakao/KakaoOAuthClient.java b/src/main/java/com/jjikmeok/app/domain/auth/client/kakao/KakaoOAuthClient.java index 704a9fa..b7be3b0 100644 --- a/src/main/java/com/jjikmeok/app/domain/auth/client/kakao/KakaoOAuthClient.java +++ b/src/main/java/com/jjikmeok/app/domain/auth/client/kakao/KakaoOAuthClient.java @@ -1,14 +1,15 @@ package com.jjikmeok.app.domain.auth.client.kakao; +import com.jjikmeok.app.domain.auth.config.KakaoOAuthProperties; import com.jjikmeok.app.global.common.exception.CustomException; import com.jjikmeok.app.global.common.exception.ErrorCode; import jakarta.validation.constraints.NotBlank; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import org.springframework.validation.annotation.Validated; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; @@ -18,37 +19,32 @@ @Component public class KakaoOAuthClient { - private static final String KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"; - private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; private static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"; private final RestClient restClient; - private final String clientId; - private final String redirectUri; + private final KakaoOAuthProperties kakaoOAuthProperties; public KakaoOAuthClient( final RestClient.Builder restClientBuilder, - @Value("${oauth2.kakao.client-id}") final String clientId, - @Value("${oauth2.kakao.redirect-uri}") final String redirectUri + final KakaoOAuthProperties kakaoOAuthProperties ) { this.restClient = restClientBuilder.build(); - this.clientId = clientId; - this.redirectUri = redirectUri; + this.kakaoOAuthProperties = kakaoOAuthProperties; } - public String getAccessToken(@NotBlank final String code) { + public KakaoOAuthRes.TokenResponse getToken(@NotBlank final String code) { try { final KakaoOAuthRes.TokenResponse tokenResponse = restClient.post() - .uri(KAKAO_TOKEN_URL) + .uri(kakaoOAuthProperties.getTokenUri()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(createTokenRequestBody(code)) .retrieve() .body(KakaoOAuthRes.TokenResponse.class); validateTokenResponse(tokenResponse); - return tokenResponse.accessToken(); + return tokenResponse; } catch (final RestClientException e) { - log.error("카카오 액세스 토큰 요청에 실패했습니다.", e); + log.error("Kakao access token request failed.", e); throw new CustomException(ErrorCode.AUTH_INVALID_SOCIAL_ACCESS_TOKEN); } } @@ -56,7 +52,7 @@ public String getAccessToken(@NotBlank final String code) { public KakaoOAuthRes.UserInfoResponse getUserInfo(@NotBlank final String accessToken) { try { final KakaoOAuthRes.UserInfoResponse userInfo = restClient.get() - .uri(KAKAO_USER_INFO_URL) + .uri(kakaoOAuthProperties.getUserInfoUri()) .headers(headers -> headers.setBearerAuth(accessToken)) .retrieve() .body(KakaoOAuthRes.UserInfoResponse.class); @@ -64,7 +60,7 @@ public KakaoOAuthRes.UserInfoResponse getUserInfo(@NotBlank final String accessT validateUserInfoResponse(userInfo); return userInfo; } catch (final RestClientException e) { - log.error("카카오 사용자 정보 조회에 실패했습니다.", e); + log.error("Kakao user info request failed.", e); throw new CustomException(ErrorCode.AUTH_INVALID_SOCIAL_ACCESS_TOKEN); } } @@ -84,8 +80,11 @@ private void validateUserInfoResponse(final KakaoOAuthRes.UserInfoResponse userI private MultiValueMap createTokenRequestBody(final String code) { final MultiValueMap body = new LinkedMultiValueMap<>(); body.add("grant_type", GRANT_TYPE_AUTHORIZATION_CODE); - body.add("client_id", clientId); - body.add("redirect_uri", redirectUri); + body.add("client_id", kakaoOAuthProperties.getClientId()); + if (StringUtils.hasText(kakaoOAuthProperties.getClientSecret())) { + body.add("client_secret", kakaoOAuthProperties.getClientSecret()); + } + body.add("redirect_uri", kakaoOAuthProperties.getRedirectUri()); body.add("code", code); return body; } diff --git a/src/main/java/com/jjikmeok/app/domain/auth/config/GoogleOAuthProperties.java b/src/main/java/com/jjikmeok/app/domain/auth/config/GoogleOAuthProperties.java new file mode 100644 index 0000000..de4e7f4 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/config/GoogleOAuthProperties.java @@ -0,0 +1,53 @@ +package com.jjikmeok.app.domain.auth.config; + +import java.time.Duration; +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "oauth2.google") +public class GoogleOAuthProperties { + + @NotBlank + private String clientId; + + @NotBlank + private String clientSecret; + + @NotBlank + private String redirectUri; + + @NotBlank + private String authorizationUri; + + @NotBlank + private String tokenUri; + + @NotBlank + private String userInfoUri; + + @NotEmpty + private List scopes = List.of("openid", "email", "profile"); + + private Duration stateTtl = Duration.ofMinutes(5); + + private Duration handoffTtl = Duration.ofMinutes(5); + + @NotBlank + private String appDeepLinkUri; + + @Positive + private int stateTokenBytes = 32; + + @Positive + private int handoffTokenBytes = 32; +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/config/KakaoOAuthProperties.java b/src/main/java/com/jjikmeok/app/domain/auth/config/KakaoOAuthProperties.java new file mode 100644 index 0000000..6516060 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/config/KakaoOAuthProperties.java @@ -0,0 +1,52 @@ +package com.jjikmeok.app.domain.auth.config; + +import java.time.Duration; +import java.util.List; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "oauth2.kakao") +public class KakaoOAuthProperties { + + @NotBlank + private String clientId; + + private String clientSecret; + + @NotBlank + private String redirectUri; + + @NotBlank + private String authorizationUri; + + @NotBlank + private String tokenUri; + + @NotBlank + private String userInfoUri; + + @NotEmpty + private List scopes = List.of("account_email", "profile_nickname"); + + private Duration stateTtl = Duration.ofMinutes(5); + + private Duration handoffTtl = Duration.ofMinutes(5); + + @NotBlank + private String appDeepLinkUri; + + @Positive + private int stateTokenBytes = 32; + + @Positive + private int handoffTokenBytes = 32; +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/controller/AuthController.java b/src/main/java/com/jjikmeok/app/domain/auth/controller/AuthController.java index 7f6a76a..2cf4fa9 100644 --- a/src/main/java/com/jjikmeok/app/domain/auth/controller/AuthController.java +++ b/src/main/java/com/jjikmeok/app/domain/auth/controller/AuthController.java @@ -1,15 +1,13 @@ package com.jjikmeok.app.domain.auth.controller; +import com.jjikmeok.app.domain.auth.dto.request.HandoffTokenReq; import com.jjikmeok.app.domain.auth.dto.request.LoginReq; import com.jjikmeok.app.domain.auth.dto.request.ReissueReq; -import com.jjikmeok.app.domain.auth.dto.request.SocialLoginReq; import com.jjikmeok.app.domain.auth.dto.request.SignupReq; import com.jjikmeok.app.domain.auth.dto.response.LoginRes; import com.jjikmeok.app.domain.auth.dto.response.ReissueRes; import com.jjikmeok.app.domain.auth.dto.response.SignupRes; import com.jjikmeok.app.domain.auth.service.AuthService; -import com.jjikmeok.app.domain.auth.service.GoogleAuthService; -import com.jjikmeok.app.domain.auth.service.KakaoAuthService; import com.jjikmeok.app.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,16 +21,14 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/auth") -@Tag(name = "Auth", description = "인증 관련 API") +@Tag(name = "Auth", description = "Authentication API") public class AuthController { private final AuthService authService; - private final GoogleAuthService googleAuthService; - private final KakaoAuthService kakaoAuthService; @Operation( - summary = "회원가입", - description = "이메일과 비밀번호로 로컬 계정을 생성합니다." + summary = "Signup", + description = "Creates a local account with email and password." ) @PostMapping("/signup") public ApiResponse signup(@Valid @RequestBody final SignupReq request) { @@ -41,8 +37,8 @@ public ApiResponse signup(@Valid @RequestBody final SignupReq request } @Operation( - summary = "로그인", - description = "이메일과 비밀번호로 로그인하고 access token과 refresh token을 발급합니다." + summary = "Login", + description = "Authenticates a local account and issues access and refresh tokens." ) @PostMapping("/login") public ApiResponse login(@Valid @RequestBody final LoginReq request) { @@ -51,8 +47,8 @@ public ApiResponse login(@Valid @RequestBody final LoginReq request) { } @Operation( - summary = "토큰 재발급", - description = "Request Body의 Refresh Token을 검증한 뒤 Access Token과 Refresh Token을 재발급합니다." + summary = "Reissue tokens", + description = "Validates a refresh token and reissues access and refresh tokens." ) @PostMapping("/reissue") public ApiResponse reissue(@Valid @RequestBody final ReissueReq request) { @@ -61,22 +57,12 @@ public ApiResponse reissue(@Valid @RequestBody final ReissueReq requ } @Operation( - summary = "구글 로그인", - description = "구글 인가 코드를 받아 소셜 로그인 후 access token과 refresh token을 발급합니다." + summary = "Exchange handoff token", + description = "Consumes a one-time handoff token and issues service access and refresh tokens." ) - @PostMapping("/google/login") - public ApiResponse googleLogin(@Valid @RequestBody final SocialLoginReq request) { - final LoginRes response = googleAuthService.googleLogin(request.code()); - return ApiResponse.success(response); - } - - @Operation( - summary = "카카오 로그인", - description = "카카오 인가 코드를 받아 소셜 로그인 후 access token과 refresh token을 발급합니다." - ) - @PostMapping("/kakao/login") - public ApiResponse kakaoLogin(@Valid @RequestBody final SocialLoginReq request) { - final LoginRes response = kakaoAuthService.kakaoLogin(request.code()); + @PostMapping("/handoff") + public ApiResponse exchangeHandoffToken(@Valid @RequestBody final HandoffTokenReq request) { + final LoginRes response = authService.exchangeHandoffToken(request.handoffToken()); return ApiResponse.success(response); } } diff --git a/src/main/java/com/jjikmeok/app/domain/auth/controller/OAuthController.java b/src/main/java/com/jjikmeok/app/domain/auth/controller/OAuthController.java new file mode 100644 index 0000000..a7a5ba3 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/controller/OAuthController.java @@ -0,0 +1,80 @@ +package com.jjikmeok.app.domain.auth.controller; + +import java.net.URI; + +import com.jjikmeok.app.domain.auth.service.GoogleOAuthHandoffService; +import com.jjikmeok.app.domain.auth.service.KakaoOAuthHandoffService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/oauth") +@Tag(name = "OAuth", description = "Backend-driven OAuth login API") +public class OAuthController { + + private final GoogleOAuthHandoffService googleOAuthHandoffService; + private final KakaoOAuthHandoffService kakaoOAuthHandoffService; + + @Operation( + summary = "Start Google OAuth login", + description = "Creates a CSRF state and redirects to the Google OAuth authorization URL." + ) + @GetMapping("/google/login") + public ResponseEntity googleLogin() { + final URI redirectUri = googleOAuthHandoffService.createGoogleLoginUri(); + return redirect(redirectUri); + } + + @Operation( + summary = "Google OAuth callback", + description = "Handles Google authorization code and redirects to the app deep link with a handoff token." + ) + @GetMapping("/google/callback") + public ResponseEntity googleCallback( + @RequestParam(required = false) final String code, + @RequestParam(required = false) final String state, + @RequestParam(required = false) final String error + ) { + final URI appDeepLinkUri = googleOAuthHandoffService.handleGoogleCallback(code, state, error); + return redirect(appDeepLinkUri); + } + + @Operation( + summary = "Start Kakao OAuth login", + description = "Creates a CSRF state and redirects to the Kakao OAuth authorization URL." + ) + @GetMapping("/kakao/login") + public ResponseEntity kakaoLogin() { + final URI redirectUri = kakaoOAuthHandoffService.createKakaoLoginUri(); + return redirect(redirectUri); + } + + @Operation( + summary = "Kakao OAuth callback", + description = "Handles Kakao authorization code and redirects to the app deep link with a handoff token." + ) + @GetMapping("/kakao/callback") + public ResponseEntity kakaoCallback( + @RequestParam(required = false) final String code, + @RequestParam(required = false) final String state, + @RequestParam(required = false) final String error + ) { + final URI appDeepLinkUri = kakaoOAuthHandoffService.handleKakaoCallback(code, state, error); + return redirect(appDeepLinkUri); + } + + private ResponseEntity redirect(final URI location) { + return ResponseEntity + .status(302) + .header(HttpHeaders.LOCATION, location.toString()) + .build(); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/dto/request/HandoffTokenReq.java b/src/main/java/com/jjikmeok/app/domain/auth/dto/request/HandoffTokenReq.java new file mode 100644 index 0000000..9b69e1f --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/dto/request/HandoffTokenReq.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "handoff token 교환 요청") +public record HandoffTokenReq( + + @Schema(description = "1회용 handoff token", example = "A1b2C3d4...") + @NotBlank(message = "handoff token은 필수입니다.") + String handoffToken +) { +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/dto/request/SocialLoginReq.java b/src/main/java/com/jjikmeok/app/domain/auth/dto/request/SocialLoginReq.java deleted file mode 100644 index e17143c..0000000 --- a/src/main/java/com/jjikmeok/app/domain/auth/dto/request/SocialLoginReq.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.jjikmeok.app.domain.auth.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; - -@Schema(description = "소셜 로그인 요청") -public record SocialLoginReq( - - @Schema(description = "소셜 로그인 인가 코드", example = "4/0AQSTgQ...") - @NotBlank(message = "인가 코드는 필수입니다.") - String code -) { -} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/service/AuthService.java b/src/main/java/com/jjikmeok/app/domain/auth/service/AuthService.java index 56db676..13808b6 100644 --- a/src/main/java/com/jjikmeok/app/domain/auth/service/AuthService.java +++ b/src/main/java/com/jjikmeok/app/domain/auth/service/AuthService.java @@ -6,7 +6,9 @@ import com.jjikmeok.app.domain.auth.dto.response.LoginRes; import com.jjikmeok.app.domain.auth.dto.response.ReissueRes; import com.jjikmeok.app.domain.auth.dto.response.SignupRes; +import com.jjikmeok.app.domain.auth.store.HandoffTokenStore; import com.jjikmeok.app.domain.auth.store.RefreshTokenStore; +import com.jjikmeok.app.domain.auth.token.HandoffTokenEntry; import com.jjikmeok.app.domain.user.entity.AuthProvider; import com.jjikmeok.app.domain.user.entity.User; import com.jjikmeok.app.domain.user.repository.UserRepository; @@ -34,7 +36,11 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; private final JwtProperties jwtProperties; private final RefreshTokenStore refreshTokenStore; + private final HandoffTokenStore handoffTokenStore; + /** + * 이메일 회원가입 + */ @Transactional public SignupRes signup(final SignupReq request) { final String email = AuthUtils.normalizeEmail(request.email()); @@ -44,10 +50,13 @@ public SignupRes signup(final SignupReq request) { final User user = User.createForSignup(email, encodedPassword); final User saved = saveUserOrThrowDuplicateEmail(user, email); - log.info("회원가입 완료 - email: {}, userId: {}", saved.getEmail(), saved.getId()); + log.info("Signup completed. email={}, userId={}", saved.getEmail(), saved.getId()); return new SignupRes(saved.getId(), saved.getEmail()); } + /** + * 이메일 로그인 + */ @Transactional public LoginRes login(final LoginReq request) { final String email = AuthUtils.normalizeEmail(request.email()); @@ -56,17 +65,12 @@ public LoginRes login(final LoginReq request) { validateLocalLogin(user); validatePasswordMatches(request.password(), user.getPasswordHash()); - final Long userId = user.getId(); - final String role = AuthUtils.resolveRole(user.getRole()); - final String accessToken = jwtTokenProvider.createAccessToken(userId, role); - final String refreshToken = jwtTokenProvider.createRefreshToken(userId); - final int expiresIn = AuthUtils.accessTokenExpiresInSeconds(jwtProperties); - - refreshTokenStore.saveToken(userId, refreshToken, AuthUtils.refreshTokenTtl(jwtProperties)); - - return new LoginRes(accessToken, refreshToken, TOKEN_TYPE, expiresIn, user.getRegistrationStatus()); + return issueLoginTokens(user); } + /** + * accessToken 만료시, refreshToken 으로 accessToken 재발행 + */ @Transactional public ReissueRes reissue(final ReissueReq request) { final String refreshToken = request.refreshToken(); @@ -82,6 +86,19 @@ public ReissueRes reissue(final ReissueReq request) { return rotateAndIssueTokens(user); } + /** + * 소셜 로그인시, 클라이언트가 서버로 부터 받은 handoffToken 을 통해서 accessToken, refreshToken 을 발급 + */ + @Transactional + public LoginRes exchangeHandoffToken(final String handoffToken) { + final HandoffTokenEntry entry = handoffTokenStore.consume(handoffToken) + .orElseThrow(() -> new CustomException(ErrorCode.AUTH_HANDOFF_TOKEN_INVALID)); + final User user = userRepository.findById(entry.memberId()) + .orElseThrow(() -> new CustomException(ErrorCode.AUTH_UNAUTHORIZED)); + + return issueLoginTokens(user); + } + private void validateEmailNotExists(final String email) { if (userRepository.existsByEmail(email)) { throw new CustomException(ErrorCode.SIGNUP_FAILED); @@ -97,7 +114,7 @@ private User saveUserOrThrowDuplicateEmail(final User user, final String email) try { return userRepository.save(user); } catch (final DataIntegrityViolationException e) { - log.debug("회원가입 저장 중 이메일 중복으로 실패했습니다. email={}", email); + log.debug("Signup failed because email already exists. email={}", email); throw new CustomException(ErrorCode.SIGNUP_FAILED); } } @@ -114,6 +131,18 @@ private void validatePasswordMatches(final String rawPassword, final String enco } } + private LoginRes issueLoginTokens(final User user) { + final Long userId = user.getId(); + final String role = AuthUtils.resolveRole(user.getRole()); + final String accessToken = jwtTokenProvider.createAccessToken(userId, role); + final String refreshToken = jwtTokenProvider.createRefreshToken(userId); + final int expiresIn = AuthUtils.accessTokenExpiresInSeconds(jwtProperties); + + refreshTokenStore.saveToken(userId, refreshToken, AuthUtils.refreshTokenTtl(jwtProperties)); + + return new LoginRes(accessToken, refreshToken, TOKEN_TYPE, expiresIn, user.getRegistrationStatus()); + } + private ReissueRes rotateAndIssueTokens(final User user) { final Long userId = user.getId(); final String role = AuthUtils.resolveRole(user.getRole()); diff --git a/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleAuthService.java b/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleAuthService.java deleted file mode 100644 index 64e6f1c..0000000 --- a/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleAuthService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.jjikmeok.app.domain.auth.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.jjikmeok.app.domain.auth.client.google.GoogleOAuthClient; -import com.jjikmeok.app.domain.auth.client.google.GoogleOAuthRes; -import com.jjikmeok.app.domain.auth.dto.response.LoginRes; -import com.jjikmeok.app.domain.auth.store.RefreshTokenStore; -import com.jjikmeok.app.domain.user.entity.AuthProvider; -import com.jjikmeok.app.domain.user.entity.User; -import com.jjikmeok.app.domain.user.repository.UserRepository; -import com.jjikmeok.app.global.security.jwt.JwtProperties; -import com.jjikmeok.app.global.security.jwt.JwtTokenProvider; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class GoogleAuthService { - - private static final String TOKEN_TYPE = "Bearer"; - - private final GoogleOAuthClient googleOAuthClient; - private final UserRepository userRepository; - private final JwtTokenProvider jwtTokenProvider; - private final JwtProperties jwtProperties; - private final RefreshTokenStore refreshTokenStore; - - @Transactional - public LoginRes googleLogin(final String code) { - final String googleAccessToken = googleOAuthClient.getAccessToken(code); - final GoogleOAuthRes.UserInfoResponse userInfo = googleOAuthClient.getUserInfo(googleAccessToken); - final User user = findOrCreateUser(userInfo); - - final Long userId = user.getId(); - final String role = AuthUtils.resolveRole(user.getRole()); - final String accessToken = jwtTokenProvider.createAccessToken(userId, role); - final String refreshToken = jwtTokenProvider.createRefreshToken(userId); - final int expiresIn = AuthUtils.accessTokenExpiresInSeconds(jwtProperties); - - refreshTokenStore.saveToken(userId, refreshToken, AuthUtils.refreshTokenTtl(jwtProperties)); - - return new LoginRes(accessToken, refreshToken, TOKEN_TYPE, expiresIn, user.getRegistrationStatus()); - } - - private User findOrCreateUser(final GoogleOAuthRes.UserInfoResponse userInfo) { - final String providerId = userInfo.sub(); - final String email = AuthUtils.normalizeEmail(userInfo.email()); - - return userRepository.findByAuthProviderAndProviderId(AuthProvider.GOOGLE, providerId) - .orElseGet(() -> { - final User savedUser = userRepository.save(User.createForOAuth2(email, AuthProvider.GOOGLE, providerId)); - log.info("Google OAuth signup completed. userId={}, providerId={}", savedUser.getId(), providerId); - return savedUser; - }); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleOAuthHandoffService.java b/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleOAuthHandoffService.java new file mode 100644 index 0000000..8a907d6 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/service/GoogleOAuthHandoffService.java @@ -0,0 +1,135 @@ +package com.jjikmeok.app.domain.auth.service; + +import java.net.URI; +import java.time.Instant; +import java.util.StringJoiner; + +import com.jjikmeok.app.domain.auth.client.google.GoogleOAuthClient; +import com.jjikmeok.app.domain.auth.client.google.GoogleOAuthRes; +import com.jjikmeok.app.domain.auth.config.GoogleOAuthProperties; +import com.jjikmeok.app.domain.auth.store.HandoffTokenStore; +import com.jjikmeok.app.domain.auth.store.OAuthStateStore; +import com.jjikmeok.app.domain.auth.token.HandoffTokenEntry; +import com.jjikmeok.app.domain.auth.token.OAuthTokenGenerator; +import com.jjikmeok.app.domain.user.entity.AuthProvider; +import com.jjikmeok.app.domain.user.entity.User; +import com.jjikmeok.app.domain.user.repository.UserRepository; +import com.jjikmeok.app.global.common.exception.CustomException; +import com.jjikmeok.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class GoogleOAuthHandoffService { + + private final GoogleOAuthClient googleOAuthClient; + private final GoogleOAuthProperties googleOAuthProperties; + private final OAuthStateStore oAuthStateStore; + private final HandoffTokenStore handoffTokenStore; + private final OAuthTokenGenerator oAuthTokenGenerator; + private final UserRepository userRepository; + + public URI createGoogleLoginUri() { + final String state = oAuthTokenGenerator.generateUrlSafeToken(googleOAuthProperties.getStateTokenBytes()); + oAuthStateStore.save(state, googleOAuthProperties.getStateTtl()); + + return UriComponentsBuilder.fromUriString(googleOAuthProperties.getAuthorizationUri()) + .queryParam("client_id", googleOAuthProperties.getClientId()) + .queryParam("redirect_uri", googleOAuthProperties.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", createScopeValue()) + .queryParam("state", state) + .queryParam("access_type", "offline") + .queryParam("prompt", "select_account") + .build() + .encode() + .toUri(); + } + + @Transactional + public URI handleGoogleCallback(final String code, final String state, final String error) { + validateCallbackError(error); + validateState(state); + validateCode(code); + + final GoogleOAuthRes.TokenResponse tokenResponse = googleOAuthClient.getToken(code); + final GoogleOAuthRes.UserInfoResponse userInfo = googleOAuthClient.getUserInfo(tokenResponse.accessToken()); + final OAuthUserResult userResult = findOrCreateUser(userInfo); + final String handoffToken = createHandoffToken(userResult); + + return createAppDeepLinkUri(handoffToken); + } + + private String createScopeValue() { + final StringJoiner joiner = new StringJoiner(" "); + googleOAuthProperties.getScopes().forEach(joiner::add); + return joiner.toString(); + } + + private void validateCallbackError(final String error) { + if (error == null || error.isBlank()) { + return; + } + if ("access_denied".equals(error)) { + throw new CustomException(ErrorCode.AUTH_GOOGLE_LOGIN_CANCELLED); + } + throw new CustomException(ErrorCode.AUTH_GOOGLE_CALLBACK_FAILED); + } + + private void validateState(final String state) { + if (state == null || state.isBlank() || !oAuthStateStore.consume(state)) { + throw new CustomException(ErrorCode.AUTH_INVALID_OAUTH_STATE); + } + } + + private void validateCode(final String code) { + if (code == null || code.isBlank()) { + throw new CustomException(ErrorCode.AUTH_GOOGLE_CALLBACK_FAILED); + } + } + + private OAuthUserResult findOrCreateUser(final GoogleOAuthRes.UserInfoResponse userInfo) { + final String providerId = userInfo.sub(); + final String email = AuthUtils.normalizeEmail(userInfo.email()); + + return userRepository.findByAuthProviderAndProviderId(AuthProvider.GOOGLE, providerId) + .map(user -> new OAuthUserResult(user, false)) + .orElseGet(() -> { + final User savedUser = userRepository.save(User.createForOAuth2(email, AuthProvider.GOOGLE, providerId)); + log.info("Google OAuth handoff signup completed. userId={}, providerId={}", savedUser.getId(), providerId); + return new OAuthUserResult(savedUser, true); + }); + } + + private String createHandoffToken(final OAuthUserResult userResult) { + final String handoffToken = oAuthTokenGenerator.generateUrlSafeToken(googleOAuthProperties.getHandoffTokenBytes()); + final HandoffTokenEntry entry = new HandoffTokenEntry( + userResult.user().getId(), + userResult.newMember(), + Instant.now() + ); + handoffTokenStore.save(handoffToken, entry, googleOAuthProperties.getHandoffTtl()); + return handoffToken; + } + + private URI createAppDeepLinkUri(final String handoffToken) { + return UriComponentsBuilder.fromUriString(googleOAuthProperties.getAppDeepLinkUri()) + .queryParam("handoffToken", handoffToken) + .build() + .encode() + .toUri(); + } + + private record OAuthUserResult( + + User user, + boolean newMember + ) { + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoAuthService.java b/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoAuthService.java deleted file mode 100644 index d52a7fc..0000000 --- a/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoAuthService.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.jjikmeok.app.domain.auth.service; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.jjikmeok.app.domain.auth.client.kakao.KakaoOAuthClient; -import com.jjikmeok.app.domain.auth.client.kakao.KakaoOAuthRes; -import com.jjikmeok.app.domain.auth.dto.response.LoginRes; -import com.jjikmeok.app.domain.auth.store.RefreshTokenStore; -import com.jjikmeok.app.domain.user.entity.AuthProvider; -import com.jjikmeok.app.domain.user.entity.User; -import com.jjikmeok.app.domain.user.repository.UserRepository; -import com.jjikmeok.app.global.security.jwt.JwtProperties; -import com.jjikmeok.app.global.security.jwt.JwtTokenProvider; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class KakaoAuthService { - - private static final String TOKEN_TYPE = "Bearer"; - - private final KakaoOAuthClient kakaoOAuthClient; - private final UserRepository userRepository; - private final JwtTokenProvider jwtTokenProvider; - private final JwtProperties jwtProperties; - private final RefreshTokenStore refreshTokenStore; - - @Transactional - public LoginRes kakaoLogin(final String code) { - final String kakaoAccessToken = kakaoOAuthClient.getAccessToken(code); - final KakaoOAuthRes.UserInfoResponse userInfo = kakaoOAuthClient.getUserInfo(kakaoAccessToken); - final User user = findOrCreateUser(userInfo); - - final Long userId = user.getId(); - final String role = AuthUtils.resolveRole(user.getRole()); - final String accessToken = jwtTokenProvider.createAccessToken(userId, role); - final String refreshToken = jwtTokenProvider.createRefreshToken(userId); - final int expiresIn = AuthUtils.accessTokenExpiresInSeconds(jwtProperties); - - refreshTokenStore.saveToken(userId, refreshToken, AuthUtils.refreshTokenTtl(jwtProperties)); - - return new LoginRes(accessToken, refreshToken, TOKEN_TYPE, expiresIn, user.getRegistrationStatus()); - } - - private User findOrCreateUser(final KakaoOAuthRes.UserInfoResponse userInfo) { - final String providerId = String.valueOf(userInfo.id()); - final String email = AuthUtils.normalizeEmail(userInfo.kakaoAccount().email()); - - return userRepository.findByAuthProviderAndProviderId(AuthProvider.KAKAO, providerId) - .orElseGet(() -> { - final User savedUser = userRepository.save(User.createForOAuth2(email, AuthProvider.KAKAO, providerId)); - log.info("Kakao OAuth signup completed. userId={}, providerId={}", savedUser.getId(), providerId); - return savedUser; - }); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoOAuthHandoffService.java b/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoOAuthHandoffService.java new file mode 100644 index 0000000..2bb5c37 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/service/KakaoOAuthHandoffService.java @@ -0,0 +1,130 @@ +package com.jjikmeok.app.domain.auth.service; + +import java.net.URI; +import java.time.Instant; +import java.util.StringJoiner; + +import com.jjikmeok.app.domain.auth.client.kakao.KakaoOAuthClient; +import com.jjikmeok.app.domain.auth.client.kakao.KakaoOAuthRes; +import com.jjikmeok.app.domain.auth.config.KakaoOAuthProperties; +import com.jjikmeok.app.domain.auth.store.HandoffTokenStore; +import com.jjikmeok.app.domain.auth.store.OAuthStateStore; +import com.jjikmeok.app.domain.auth.token.HandoffTokenEntry; +import com.jjikmeok.app.domain.auth.token.OAuthTokenGenerator; +import com.jjikmeok.app.domain.user.entity.AuthProvider; +import com.jjikmeok.app.domain.user.entity.User; +import com.jjikmeok.app.domain.user.repository.UserRepository; +import com.jjikmeok.app.global.common.exception.CustomException; +import com.jjikmeok.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class KakaoOAuthHandoffService { + + private final KakaoOAuthClient kakaoOAuthClient; + private final KakaoOAuthProperties kakaoOAuthProperties; + private final OAuthStateStore oAuthStateStore; + private final HandoffTokenStore handoffTokenStore; + private final OAuthTokenGenerator oAuthTokenGenerator; + private final UserRepository userRepository; + + public URI createKakaoLoginUri() { + final String state = oAuthTokenGenerator.generateUrlSafeToken(kakaoOAuthProperties.getStateTokenBytes()); + oAuthStateStore.save(state, kakaoOAuthProperties.getStateTtl()); + + return UriComponentsBuilder.fromUriString(kakaoOAuthProperties.getAuthorizationUri()) + .queryParam("client_id", kakaoOAuthProperties.getClientId()) + .queryParam("redirect_uri", kakaoOAuthProperties.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", createScopeValue()) + .queryParam("state", state) + .build() + .encode() + .toUri(); + } + + @Transactional + public URI handleKakaoCallback(final String code, final String state, final String error) { + validateCallbackError(error); + validateState(state); + validateCode(code); + + final KakaoOAuthRes.TokenResponse tokenResponse = kakaoOAuthClient.getToken(code); + final KakaoOAuthRes.UserInfoResponse userInfo = kakaoOAuthClient.getUserInfo(tokenResponse.accessToken()); + final OAuthUserResult userResult = findOrCreateUser(userInfo); + final String handoffToken = createHandoffToken(userResult); + + return createAppDeepLinkUri(handoffToken); + } + + private String createScopeValue() { + final StringJoiner joiner = new StringJoiner(" "); + kakaoOAuthProperties.getScopes().forEach(joiner::add); + return joiner.toString(); + } + + private void validateCallbackError(final String error) { + if (error == null || error.isBlank()) { + return; + } + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + private void validateState(final String state) { + if (state == null || state.isBlank() || !oAuthStateStore.consume(state)) { + throw new CustomException(ErrorCode.AUTH_INVALID_OAUTH_STATE); + } + } + + private void validateCode(final String code) { + if (code == null || code.isBlank()) { + throw new CustomException(ErrorCode.BAD_REQUEST); + } + } + + private OAuthUserResult findOrCreateUser(final KakaoOAuthRes.UserInfoResponse userInfo) { + final String providerId = String.valueOf(userInfo.id()); + final String email = AuthUtils.normalizeEmail(userInfo.kakaoAccount().email()); + + return userRepository.findByAuthProviderAndProviderId(AuthProvider.KAKAO, providerId) + .map(user -> new OAuthUserResult(user, false)) + .orElseGet(() -> { + final User savedUser = userRepository.save(User.createForOAuth2(email, AuthProvider.KAKAO, providerId)); + log.info("Kakao OAuth handoff signup completed. userId={}, providerId={}", savedUser.getId(), providerId); + return new OAuthUserResult(savedUser, true); + }); + } + + private String createHandoffToken(final OAuthUserResult userResult) { + final String handoffToken = oAuthTokenGenerator.generateUrlSafeToken(kakaoOAuthProperties.getHandoffTokenBytes()); + final HandoffTokenEntry entry = new HandoffTokenEntry( + userResult.user().getId(), + userResult.newMember(), + Instant.now() + ); + handoffTokenStore.save(handoffToken, entry, kakaoOAuthProperties.getHandoffTtl()); + return handoffToken; + } + + private URI createAppDeepLinkUri(final String handoffToken) { + return UriComponentsBuilder.fromUriString(kakaoOAuthProperties.getAppDeepLinkUri()) + .queryParam("handoffToken", handoffToken) + .build() + .encode() + .toUri(); + } + + private record OAuthUserResult( + + User user, + boolean newMember + ) { + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/store/HandoffTokenStore.java b/src/main/java/com/jjikmeok/app/domain/auth/store/HandoffTokenStore.java new file mode 100644 index 0000000..8bbb3df --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/store/HandoffTokenStore.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.auth.store; + +import java.time.Duration; +import java.util.Optional; + +import com.jjikmeok.app.domain.auth.token.HandoffTokenEntry; + +public interface HandoffTokenStore { + + void save(String token, HandoffTokenEntry entry, Duration ttl); + + Optional consume(String token); +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/store/OAuthStateStore.java b/src/main/java/com/jjikmeok/app/domain/auth/store/OAuthStateStore.java new file mode 100644 index 0000000..7bf8d55 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/store/OAuthStateStore.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.auth.store; + +import java.time.Duration; + +/** + * OAuth CSRF 방지 state 값을 저장하고 1회용으로 소비하는 저장소 + */ +public interface OAuthStateStore { + + void save(String state, Duration ttl); + + boolean consume(String state); +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/store/RedisHandoffTokenStore.java b/src/main/java/com/jjikmeok/app/domain/auth/store/RedisHandoffTokenStore.java new file mode 100644 index 0000000..18cdc83 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/store/RedisHandoffTokenStore.java @@ -0,0 +1,64 @@ +package com.jjikmeok.app.domain.auth.store; + +import java.time.Duration; +import java.util.Optional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jjikmeok.app.domain.auth.token.HandoffTokenEntry; +import com.jjikmeok.app.global.common.exception.CustomException; +import com.jjikmeok.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Redis를 사용해 handoff token을 저장하고 원자적으로 소비 + */ +@Component +@RequiredArgsConstructor +public class RedisHandoffTokenStore implements HandoffTokenStore { + + private static final String KEY_PREFIX = "handoff:"; + + private final StringRedisTemplate stringRedisTemplate; + + private final ObjectMapper objectMapper; + + @Override + public void save(final String token, final HandoffTokenEntry entry, final Duration ttl) { + stringRedisTemplate.opsForValue().set(generateKey(token), serialize(entry), ttl); + } + + @Override + public Optional consume(final String token) { + return Optional.ofNullable(stringRedisTemplate.opsForValue().getAndDelete(generateKey(token))) + .map(this::deserialize); + } + + private String serialize(final HandoffTokenEntry entry) { + try { + return objectMapper.writeValueAsString(entry); + } catch (final JsonProcessingException e) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private HandoffTokenEntry deserialize(final String value) { + try { + return objectMapper.readValue(value, HandoffTokenEntry.class); + } catch (final JsonProcessingException e) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + /** + * Redis key를 생성합니다. + * + * @param token handoff token + * @return Redis key + */ + private String generateKey(final String token) { + return KEY_PREFIX + token; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/store/RedisOAuthStateStore.java b/src/main/java/com/jjikmeok/app/domain/auth/store/RedisOAuthStateStore.java new file mode 100644 index 0000000..c26a565 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/store/RedisOAuthStateStore.java @@ -0,0 +1,33 @@ +package com.jjikmeok.app.domain.auth.store; + +import java.time.Duration; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Redis를 사용해 OAuth state 값을 저장하고 원자적으로 소비 + */ +@Component +@RequiredArgsConstructor +public class RedisOAuthStateStore implements OAuthStateStore { + + private static final String KEY_PREFIX = "oauth:state:"; + + private final StringRedisTemplate stringRedisTemplate; + + @Override + public void save(final String state, final Duration ttl) { + stringRedisTemplate.opsForValue().set(generateKey(state), "1", ttl); + } + + @Override + public boolean consume(final String state) { + return stringRedisTemplate.opsForValue().getAndDelete(generateKey(state)) != null; + } + + private String generateKey(final String state) { + return KEY_PREFIX + state; + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/token/HandoffTokenEntry.java b/src/main/java/com/jjikmeok/app/domain/auth/token/HandoffTokenEntry.java new file mode 100644 index 0000000..4f0a32c --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/token/HandoffTokenEntry.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.auth.token; + +import java.time.Instant; + +public record HandoffTokenEntry( + + Long memberId, + + boolean isNewMember, + + Instant createdAt +) { +} diff --git a/src/main/java/com/jjikmeok/app/domain/auth/token/OAuthTokenGenerator.java b/src/main/java/com/jjikmeok/app/domain/auth/token/OAuthTokenGenerator.java new file mode 100644 index 0000000..0edccd7 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/auth/token/OAuthTokenGenerator.java @@ -0,0 +1,24 @@ +package com.jjikmeok.app.domain.auth.token; + +import java.security.SecureRandom; +import java.util.Base64; + +import org.springframework.stereotype.Component; + +/** + * OAuth state 및 handoff token에 사용할 예측 불가능한 토큰을 생성한다. + */ +@Component +public class OAuthTokenGenerator { + + /** + * 난수 생성에 사용할 보안 난수 생성기 + */ + private final SecureRandom secureRandom = new SecureRandom(); + + public String generateUrlSafeToken(final int byteLength) { + final byte[] bytes = new byte[byteLength]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/favorite/controller/FavoriteController.java b/src/main/java/com/jjikmeok/app/domain/favorite/controller/FavoriteController.java new file mode 100644 index 0000000..a863d56 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/favorite/controller/FavoriteController.java @@ -0,0 +1,63 @@ +package com.jjikmeok.app.domain.favorite.controller; + +import com.jjikmeok.app.domain.favorite.dto.request.FavoriteRequest; +import com.jjikmeok.app.domain.favorite.dto.response.FavoriteResponse; +import com.jjikmeok.app.domain.favorite.service.FavoriteService; +import com.jjikmeok.app.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "찜 API", description = "활동 찜 관리 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/activity-favorites") +public class FavoriteController { + + private final FavoriteService favoriteService; + + @Operation(summary = "찜한 활동 목록 조회") + @GetMapping + public ApiResponse> getFavorites( + @AuthenticationPrincipal Long userId, + @Parameter(description = "정렬 기준: saved(담은순, 기본값), deadline(마감순)") + @RequestParam(value = "sort", required = false, defaultValue = "saved") String sort + ) { + return ApiResponse.success("찜한 활동 목록 조회 성공", favoriteService.getFavorites(userId, sort)); + } + + @Operation(summary = "찜 추가") + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse createFavorite( + @AuthenticationPrincipal Long userId, + @RequestBody @Valid FavoriteRequest request + ) { + return ApiResponse.created("찜 추가 성공", favoriteService.createFavorite(userId, request)); + } + + @Operation(summary = "찜 삭제") + @DeleteMapping("/{activityId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteFavorite( + @AuthenticationPrincipal Long userId, + @PathVariable("activityId") Long activityId + ) { + favoriteService.deleteFavorite(userId, activityId); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/favorite/converter/FavoriteConverter.java b/src/main/java/com/jjikmeok/app/domain/favorite/converter/FavoriteConverter.java new file mode 100644 index 0000000..60443e6 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/favorite/converter/FavoriteConverter.java @@ -0,0 +1,19 @@ +package com.jjikmeok.app.domain.favorite.converter; + +import com.jjikmeok.app.domain.favorite.dto.response.FavoriteResponse; +import com.jjikmeok.app.domain.favorite.entity.Favorite; + +public class FavoriteConverter { + + private FavoriteConverter() { + } + + public static FavoriteResponse toResponse(Favorite favorite) { + return new FavoriteResponse( + favorite.getId(), + favorite.getUser().getId(), + favorite.getActivity().getId(), + favorite.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityFavoriteRequest.java b/src/main/java/com/jjikmeok/app/domain/favorite/dto/request/FavoriteRequest.java similarity index 59% rename from src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityFavoriteRequest.java rename to src/main/java/com/jjikmeok/app/domain/favorite/dto/request/FavoriteRequest.java index cfa4905..848dc8d 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityFavoriteRequest.java +++ b/src/main/java/com/jjikmeok/app/domain/favorite/dto/request/FavoriteRequest.java @@ -1,8 +1,8 @@ -package com.jjikmeok.app.domain.activity.dto.request; +package com.jjikmeok.app.domain.favorite.dto.request; import jakarta.validation.constraints.NotNull; -public record ActivityFavoriteRequest( +public record FavoriteRequest( @NotNull(message = "활동 ID는 필수입니다.") Long activityId ) { diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityFavoriteResponse.java b/src/main/java/com/jjikmeok/app/domain/favorite/dto/response/FavoriteResponse.java similarity index 58% rename from src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityFavoriteResponse.java rename to src/main/java/com/jjikmeok/app/domain/favorite/dto/response/FavoriteResponse.java index dea4376..bc64161 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityFavoriteResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/favorite/dto/response/FavoriteResponse.java @@ -1,8 +1,8 @@ -package com.jjikmeok.app.domain.activity.dto.response; +package com.jjikmeok.app.domain.favorite.dto.response; import java.time.LocalDateTime; -public record ActivityFavoriteResponse( +public record FavoriteResponse( Long id, Long userId, Long activityId, diff --git a/src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityFavorite.java b/src/main/java/com/jjikmeok/app/domain/favorite/entity/Favorite.java similarity index 79% rename from src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityFavorite.java rename to src/main/java/com/jjikmeok/app/domain/favorite/entity/Favorite.java index 435c6e3..3ef50a6 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityFavorite.java +++ b/src/main/java/com/jjikmeok/app/domain/favorite/entity/Favorite.java @@ -1,5 +1,6 @@ -package com.jjikmeok.app.domain.activity.entity; +package com.jjikmeok.app.domain.favorite.entity; +import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.user.entity.User; import com.jjikmeok.app.global.common.BaseEntity; import jakarta.persistence.Entity; @@ -18,7 +19,7 @@ @UniqueConstraint(name = "uk_activity_favorites_user_activity", columnNames = {"user_id", "activity_id"}) }) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ActivityFavorite extends BaseEntity { +public class Favorite extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -28,8 +29,8 @@ public class ActivityFavorite extends BaseEntity { @JoinColumn(name = "activity_id", nullable = false) private Activity activity; - public static ActivityFavorite create(User user, Activity activity) { - ActivityFavorite favorite = new ActivityFavorite(); + public static Favorite create(User user, Activity activity) { + Favorite favorite = new Favorite(); favorite.user = user; favorite.activity = activity; return favorite; diff --git a/src/main/java/com/jjikmeok/app/domain/favorite/repository/FavoriteRepository.java b/src/main/java/com/jjikmeok/app/domain/favorite/repository/FavoriteRepository.java new file mode 100644 index 0000000..641c7e9 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/favorite/repository/FavoriteRepository.java @@ -0,0 +1,43 @@ +package com.jjikmeok.app.domain.favorite.repository; + +import com.jjikmeok.app.domain.favorite.entity.Favorite; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FavoriteRepository extends JpaRepository { + + List findAllByUserIdOrderByCreatedAtDesc(Long userId); + + @Query(""" + SELECT f + FROM Favorite f + JOIN FETCH f.activity a + JOIN FETCH f.user u + WHERE u.id = :userId + AND a.recruitEndAt IS NOT NULL + ORDER BY + a.recruitEndAt ASC, + f.createdAt DESC, + f.id DESC + """) + List findAllByUserIdOrderByRecruitEndAtAsc(@Param("userId") Long userId); + + Optional findByUserIdAndActivityId(Long userId, Long activityId); + + boolean existsByUserIdAndActivityId(Long userId, Long activityId); + + @Query(""" + SELECT f.activity.id + FROM Favorite f + WHERE f.user.id = :userId + AND f.activity.id IN :activityIds + """) + List findActivityIdsByUserIdAndActivityIdIn( + @Param("userId") Long userId, + @Param("activityIds") Collection activityIds); +} diff --git a/src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteService.java b/src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteService.java new file mode 100644 index 0000000..7b1a557 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteService.java @@ -0,0 +1,12 @@ +package com.jjikmeok.app.domain.favorite.service; + +import com.jjikmeok.app.domain.favorite.dto.request.FavoriteRequest; +import com.jjikmeok.app.domain.favorite.dto.response.FavoriteResponse; + +import java.util.List; + +public interface FavoriteService { + List getFavorites(Long userId, String sort); + FavoriteResponse createFavorite(Long userId, FavoriteRequest request); + void deleteFavorite(Long userId, Long activityId); +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteServiceImpl.java similarity index 60% rename from src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteServiceImpl.java rename to src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteServiceImpl.java index 1941e5e..fbc4f2b 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityFavoriteServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/favorite/service/FavoriteServiceImpl.java @@ -1,12 +1,12 @@ -package com.jjikmeok.app.domain.activity.service; +package com.jjikmeok.app.domain.favorite.service; -import com.jjikmeok.app.domain.activity.converter.ActivityFavoriteConverter; -import com.jjikmeok.app.domain.activity.dto.request.ActivityFavoriteRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityFavoriteResponse; import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.activity.entity.ActivityFavorite; -import com.jjikmeok.app.domain.activity.repository.ActivityFavoriteRepository; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; +import com.jjikmeok.app.domain.favorite.converter.FavoriteConverter; +import com.jjikmeok.app.domain.favorite.dto.request.FavoriteRequest; +import com.jjikmeok.app.domain.favorite.dto.response.FavoriteResponse; +import com.jjikmeok.app.domain.favorite.entity.Favorite; +import com.jjikmeok.app.domain.favorite.repository.FavoriteRepository; import com.jjikmeok.app.domain.user.entity.User; import com.jjikmeok.app.domain.user.repository.UserRepository; import com.jjikmeok.app.global.common.exception.CustomException; @@ -17,27 +17,28 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Locale; @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ActivityFavoriteServiceImpl implements ActivityFavoriteService { +public class FavoriteServiceImpl implements FavoriteService { - private final ActivityFavoriteRepository favoriteRepository; + private final FavoriteRepository favoriteRepository; private final ActivityRepository activityRepository; private final UserRepository userRepository; @Override - public List getFavorites(Long userId) { + public List getFavorites(Long userId, String sort) { findUserOrThrow(userId); - return favoriteRepository.findAllByUserIdOrderByCreatedAtDesc(userId).stream() - .map(ActivityFavoriteConverter::toResponse) + return findFavorites(userId, sort).stream() + .map(FavoriteConverter::toResponse) .toList(); } @Override @Transactional - public ActivityFavoriteResponse createFavorite(Long userId, ActivityFavoriteRequest request) { + public FavoriteResponse createFavorite(Long userId, FavoriteRequest request) { User user = findUserOrThrow(userId); Activity activity = findActivityOrThrow(request.activityId()); if (favoriteRepository.existsByUserIdAndActivityId(userId, request.activityId())) { @@ -45,9 +46,9 @@ public ActivityFavoriteResponse createFavorite(Long userId, ActivityFavoriteRequ } try { - ActivityFavorite favorite = favoriteRepository.save(ActivityFavorite.create(user, activity)); + Favorite favorite = favoriteRepository.save(Favorite.create(user, activity)); activity.increaseLikeCount(); - return ActivityFavoriteConverter.toResponse(favorite); + return FavoriteConverter.toResponse(favorite); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.ACTIVITY_FAVORITE_DUPLICATE); } @@ -58,12 +59,26 @@ public ActivityFavoriteResponse createFavorite(Long userId, ActivityFavoriteRequ public void deleteFavorite(Long userId, Long activityId) { findUserOrThrow(userId); Activity activity = findActivityOrThrow(activityId); - ActivityFavorite favorite = favoriteRepository.findByUserIdAndActivityId(userId, activityId) + Favorite favorite = favoriteRepository.findByUserIdAndActivityId(userId, activityId) .orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_FAVORITE_NOT_FOUND)); favoriteRepository.delete(favorite); activity.decreaseLikeCount(); } + private List findFavorites(Long userId, String sort) { + if ("deadline".equals(normalizeSort(sort))) { + return favoriteRepository.findAllByUserIdOrderByRecruitEndAtAsc(userId); + } + return favoriteRepository.findAllByUserIdOrderByCreatedAtDesc(userId); + } + + private String normalizeSort(String sort) { + if (sort == null || sort.isBlank()) { + return "saved"; + } + return sort.trim().toLowerCase(Locale.ROOT); + } + private User findUserOrThrow(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.AUTH_UNAUTHORIZED)); diff --git a/src/main/java/com/jjikmeok/app/domain/image/controller/ActivityImageController.java b/src/main/java/com/jjikmeok/app/domain/image/controller/ImageController.java similarity index 55% rename from src/main/java/com/jjikmeok/app/domain/image/controller/ActivityImageController.java rename to src/main/java/com/jjikmeok/app/domain/image/controller/ImageController.java index ce4d26a..09c34d8 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/controller/ActivityImageController.java +++ b/src/main/java/com/jjikmeok/app/domain/image/controller/ImageController.java @@ -1,8 +1,8 @@ package com.jjikmeok.app.domain.image.controller; -import com.jjikmeok.app.domain.image.dto.request.ActivityImageRequest; -import com.jjikmeok.app.domain.image.dto.response.ActivityImageResponse; -import com.jjikmeok.app.domain.image.service.ActivityImageService; +import com.jjikmeok.app.domain.image.dto.request.ImageRequest; +import com.jjikmeok.app.domain.image.dto.response.ImageResponse; +import com.jjikmeok.app.domain.image.service.ImageService; import com.jjikmeok.app.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,51 +22,51 @@ import java.util.List; -@Tag(name = "Activity Image", description = "활동 이미지 관리 API") +@Tag(name = "이미지 API", description = "활동 이미지 관리 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/activities/{activityId}/images") -public class ActivityImageController { +public class ImageController { - private final ActivityImageService activityImageService; + private final ImageService imageService; - @Operation(summary = "활동 이미지 목록 조회") + @Operation(summary = "이미지 목록 조회") @GetMapping - public ApiResponse> getActivityImages( + public ApiResponse> getImages( @PathVariable("activityId") Long activityId) { - return ApiResponse.success("활동 이미지 목록 조회 성공", activityImageService.getActivityImages(activityId)); + return ApiResponse.success("이미지 목록 조회 성공", imageService.getImages(activityId)); } - @Operation(summary = "활동 이미지 생성") + @Operation(summary = "이미지 생성") @PostMapping @ResponseStatus(HttpStatus.CREATED) @PreAuthorize("hasRole('ADMIN')") - public ApiResponse createActivityImage( + public ApiResponse createImage( @PathVariable("activityId") Long activityId, - @RequestBody @Valid ActivityImageRequest request) { - return ApiResponse.success("활동 이미지 생성 성공", activityImageService.createActivityImage(activityId, request)); + @RequestBody @Valid ImageRequest request) { + return ApiResponse.created("이미지 생성 성공", imageService.createImage(activityId, request)); } - @Operation(summary = "활동 이미지 수정") + @Operation(summary = "이미지 수정") @PutMapping("/{imageId}") @PreAuthorize("hasRole('ADMIN')") - public ApiResponse updateActivityImage( + public ApiResponse updateImage( @PathVariable("activityId") Long activityId, @PathVariable("imageId") Long imageId, - @RequestBody @Valid ActivityImageRequest request) { + @RequestBody @Valid ImageRequest request) { return ApiResponse.success( - "활동 이미지 수정 성공", - activityImageService.updateActivityImage(activityId, imageId, request) + "이미지 수정 성공", + imageService.updateImage(activityId, imageId, request) ); } - @Operation(summary = "활동 이미지 삭제") + @Operation(summary = "이미지 삭제") @DeleteMapping("/{imageId}") @ResponseStatus(HttpStatus.NO_CONTENT) @PreAuthorize("hasRole('ADMIN')") - public void deleteActivityImage( + public void deleteImage( @PathVariable("activityId") Long activityId, @PathVariable("imageId") Long imageId) { - activityImageService.deleteActivityImage(activityId, imageId); + imageService.deleteImage(activityId, imageId); } } diff --git a/src/main/java/com/jjikmeok/app/domain/image/converter/ActivityImageConverter.java b/src/main/java/com/jjikmeok/app/domain/image/converter/ActivityImageConverter.java deleted file mode 100644 index 8db7082..0000000 --- a/src/main/java/com/jjikmeok/app/domain/image/converter/ActivityImageConverter.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.jjikmeok.app.domain.image.converter; - -import com.jjikmeok.app.domain.image.dto.request.ActivityImageRequest; -import com.jjikmeok.app.domain.image.dto.response.ActivityImageResponse; -import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.image.entity.ActivityImage; - -public class ActivityImageConverter { - - private ActivityImageConverter() { - } - - public static ActivityImage toEntity(Activity activity, ActivityImageRequest request) { - return ActivityImage.create(activity, request.imageUrl().trim(), request.sortOrder(), request.isThumbnail()); - } - - public static ActivityImageResponse toResponse(ActivityImage activityImage) { - return new ActivityImageResponse( - activityImage.getId(), - activityImage.getActivity().getId(), - activityImage.getImageUrl(), - activityImage.getSortOrder(), - activityImage.getIsThumbnail(), - activityImage.getCreatedAt() - ); - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/image/converter/ImageConverter.java b/src/main/java/com/jjikmeok/app/domain/image/converter/ImageConverter.java new file mode 100644 index 0000000..aeb573f --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/image/converter/ImageConverter.java @@ -0,0 +1,27 @@ +package com.jjikmeok.app.domain.image.converter; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.image.dto.request.ImageRequest; +import com.jjikmeok.app.domain.image.dto.response.ImageResponse; +import com.jjikmeok.app.domain.image.entity.Image; + +public class ImageConverter { + + private ImageConverter() { + } + + public static Image toEntity(Activity activity, ImageRequest request) { + return Image.create(activity, request.imageUrl().trim(), request.sortOrder(), request.isThumbnail()); + } + + public static ImageResponse toResponse(Image image) { + return new ImageResponse( + image.getId(), + image.getActivity().getId(), + image.getImageUrl(), + image.getSortOrder(), + image.getIsThumbnail(), + image.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/image/dto/request/ActivityImageRequest.java b/src/main/java/com/jjikmeok/app/domain/image/dto/request/ImageRequest.java similarity index 93% rename from src/main/java/com/jjikmeok/app/domain/image/dto/request/ActivityImageRequest.java rename to src/main/java/com/jjikmeok/app/domain/image/dto/request/ImageRequest.java index 64b384a..c27d019 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/dto/request/ActivityImageRequest.java +++ b/src/main/java/com/jjikmeok/app/domain/image/dto/request/ImageRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -public record ActivityImageRequest( +public record ImageRequest( @NotBlank(message = "활동 이미지 URL은 필수입니다.") @Size(max = 500, message = "활동 이미지 URL은 500자 이하여야 합니다.") String imageUrl, diff --git a/src/main/java/com/jjikmeok/app/domain/image/dto/response/ActivityImageResponse.java b/src/main/java/com/jjikmeok/app/domain/image/dto/response/ImageResponse.java similarity index 86% rename from src/main/java/com/jjikmeok/app/domain/image/dto/response/ActivityImageResponse.java rename to src/main/java/com/jjikmeok/app/domain/image/dto/response/ImageResponse.java index f3fea8e..d05a5d7 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/dto/response/ActivityImageResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/image/dto/response/ImageResponse.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; -public record ActivityImageResponse( +public record ImageResponse( Long id, Long activityId, String imageUrl, diff --git a/src/main/java/com/jjikmeok/app/domain/image/entity/ActivityImage.java b/src/main/java/com/jjikmeok/app/domain/image/entity/Image.java similarity index 75% rename from src/main/java/com/jjikmeok/app/domain/image/entity/ActivityImage.java rename to src/main/java/com/jjikmeok/app/domain/image/entity/Image.java index babd494..971f4c6 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/entity/ActivityImage.java +++ b/src/main/java/com/jjikmeok/app/domain/image/entity/Image.java @@ -25,7 +25,7 @@ } ) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ActivityImage extends BaseEntity { +public class Image extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "activity_id", nullable = false) @@ -40,13 +40,13 @@ public class ActivityImage extends BaseEntity { @Column(name = "is_thumbnail", nullable = false) private Boolean isThumbnail; - public static ActivityImage create(Activity activity, String imageUrl, Integer sortOrder, Boolean isThumbnail) { - ActivityImage activityImage = new ActivityImage(); - activityImage.activity = activity; - activityImage.imageUrl = imageUrl; - activityImage.sortOrder = sortOrder != null ? sortOrder : 0; - activityImage.isThumbnail = isThumbnail != null ? isThumbnail : false; - return activityImage; + public static Image create(Activity activity, String imageUrl, Integer sortOrder, Boolean isThumbnail) { + Image image = new Image(); + image.activity = activity; + image.imageUrl = imageUrl; + image.sortOrder = sortOrder != null ? sortOrder : 0; + image.isThumbnail = isThumbnail != null ? isThumbnail : false; + return image; } public void update(String imageUrl, Integer sortOrder, Boolean isThumbnail) { diff --git a/src/main/java/com/jjikmeok/app/domain/image/repository/ActivityImageRepository.java b/src/main/java/com/jjikmeok/app/domain/image/repository/ImageRepository.java similarity index 53% rename from src/main/java/com/jjikmeok/app/domain/image/repository/ActivityImageRepository.java rename to src/main/java/com/jjikmeok/app/domain/image/repository/ImageRepository.java index 2f2e269..e764bb7 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/repository/ActivityImageRepository.java +++ b/src/main/java/com/jjikmeok/app/domain/image/repository/ImageRepository.java @@ -1,16 +1,16 @@ package com.jjikmeok.app.domain.image.repository; -import com.jjikmeok.app.domain.image.entity.ActivityImage; +import com.jjikmeok.app.domain.image.entity.Image; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.Optional; -public interface ActivityImageRepository extends JpaRepository { +public interface ImageRepository extends JpaRepository { - List findAllByActivityIdOrderBySortOrderAscIdAsc(Long activityId); + List findAllByActivityIdOrderBySortOrderAscIdAsc(Long activityId); - Optional findByIdAndActivityId(Long id, Long activityId); + Optional findByIdAndActivityId(Long id, Long activityId); boolean existsByActivityIdAndSortOrder(Long activityId, Integer sortOrder); diff --git a/src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageService.java b/src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageService.java deleted file mode 100644 index b757d53..0000000 --- a/src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageService.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.jjikmeok.app.domain.image.service; - -import com.jjikmeok.app.domain.image.dto.request.ActivityImageRequest; -import com.jjikmeok.app.domain.image.dto.response.ActivityImageResponse; - -import java.util.List; - -public interface ActivityImageService { - - List getActivityImages(Long activityId); - - ActivityImageResponse createActivityImage(Long activityId, ActivityImageRequest request); - - ActivityImageResponse updateActivityImage(Long activityId, Long imageId, ActivityImageRequest request); - - void deleteActivityImage(Long activityId, Long imageId); -} diff --git a/src/main/java/com/jjikmeok/app/domain/image/service/ImageService.java b/src/main/java/com/jjikmeok/app/domain/image/service/ImageService.java new file mode 100644 index 0000000..8fdb282 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/image/service/ImageService.java @@ -0,0 +1,17 @@ +package com.jjikmeok.app.domain.image.service; + +import com.jjikmeok.app.domain.image.dto.request.ImageRequest; +import com.jjikmeok.app.domain.image.dto.response.ImageResponse; + +import java.util.List; + +public interface ImageService { + + List getImages(Long activityId); + + ImageResponse createImage(Long activityId, ImageRequest request); + + ImageResponse updateImage(Long activityId, Long imageId, ImageRequest request); + + void deleteImage(Long activityId, Long imageId); +} diff --git a/src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/image/service/ImageServiceImpl.java similarity index 64% rename from src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImpl.java rename to src/main/java/com/jjikmeok/app/domain/image/service/ImageServiceImpl.java index 0136684..c4c541a 100644 --- a/src/main/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/image/service/ImageServiceImpl.java @@ -1,12 +1,12 @@ package com.jjikmeok.app.domain.image.service; -import com.jjikmeok.app.domain.image.converter.ActivityImageConverter; -import com.jjikmeok.app.domain.image.dto.request.ActivityImageRequest; -import com.jjikmeok.app.domain.image.dto.response.ActivityImageResponse; import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.image.entity.ActivityImage; -import com.jjikmeok.app.domain.image.repository.ActivityImageRepository; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; +import com.jjikmeok.app.domain.image.converter.ImageConverter; +import com.jjikmeok.app.domain.image.dto.request.ImageRequest; +import com.jjikmeok.app.domain.image.dto.response.ImageResponse; +import com.jjikmeok.app.domain.image.entity.Image; +import com.jjikmeok.app.domain.image.repository.ImageRepository; import com.jjikmeok.app.global.common.exception.CustomException; import com.jjikmeok.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -21,29 +21,29 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ActivityImageServiceImpl implements ActivityImageService { +public class ImageServiceImpl implements ImageService { private final ActivityRepository activityRepository; - private final ActivityImageRepository activityImageRepository; + private final ImageRepository imageRepository; @Override - public List getActivityImages(Long activityId) { + public List getImages(Long activityId) { validateActivityExists(activityId); - return activityImageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(activityId).stream() - .map(ActivityImageConverter::toResponse) + return imageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(activityId).stream() + .map(ImageConverter::toResponse) .toList(); } @Override @Transactional - public ActivityImageResponse createActivityImage(Long activityId, ActivityImageRequest request) { + public ImageResponse createImage(Long activityId, ImageRequest request) { Activity activity = findActivityOrThrow(activityId); validateImageUrl(request.imageUrl()); validateDuplicateSortOrderOnCreate(activityId, request.sortOrder()); try { - return ActivityImageConverter.toResponse( - activityImageRepository.save(ActivityImageConverter.toEntity(activity, request)) + return ImageConverter.toResponse( + imageRepository.save(ImageConverter.toEntity(activity, request)) ); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.ACTIVITY_IMAGE_DUPLICATE_SORT_ORDER); @@ -52,22 +52,22 @@ public ActivityImageResponse createActivityImage(Long activityId, ActivityImageR @Override @Transactional - public ActivityImageResponse updateActivityImage(Long activityId, Long imageId, ActivityImageRequest request) { + public ImageResponse updateImage(Long activityId, Long imageId, ImageRequest request) { validateActivityExists(activityId); validateImageUrl(request.imageUrl()); - ActivityImage activityImage = findActivityImageOrThrow(activityId, imageId); + Image image = findImageOrThrow(activityId, imageId); validateDuplicateSortOrderOnUpdate(activityId, imageId, request.sortOrder()); - activityImage.update(request.imageUrl().trim(), request.sortOrder(), request.isThumbnail()); - return ActivityImageConverter.toResponse(activityImage); + image.update(request.imageUrl().trim(), request.sortOrder(), request.isThumbnail()); + return ImageConverter.toResponse(image); } @Override @Transactional - public void deleteActivityImage(Long activityId, Long imageId) { + public void deleteImage(Long activityId, Long imageId) { validateActivityExists(activityId); - activityImageRepository.delete(findActivityImageOrThrow(activityId, imageId)); + imageRepository.delete(findImageOrThrow(activityId, imageId)); } private Activity findActivityOrThrow(Long activityId) { @@ -75,8 +75,8 @@ private Activity findActivityOrThrow(Long activityId) { .orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_NOT_FOUND)); } - private ActivityImage findActivityImageOrThrow(Long activityId, Long imageId) { - return activityImageRepository.findByIdAndActivityId(imageId, activityId) + private Image findImageOrThrow(Long activityId, Long imageId) { + return imageRepository.findByIdAndActivityId(imageId, activityId) .orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_IMAGE_NOT_FOUND)); } @@ -88,14 +88,14 @@ private void validateActivityExists(Long activityId) { private void validateDuplicateSortOrderOnCreate(Long activityId, Integer sortOrder) { int normalizedSortOrder = sortOrder != null ? sortOrder : 0; - if (activityImageRepository.existsByActivityIdAndSortOrder(activityId, normalizedSortOrder)) { + if (imageRepository.existsByActivityIdAndSortOrder(activityId, normalizedSortOrder)) { throw new CustomException(ErrorCode.ACTIVITY_IMAGE_DUPLICATE_SORT_ORDER); } } private void validateDuplicateSortOrderOnUpdate(Long activityId, Long imageId, Integer sortOrder) { int normalizedSortOrder = sortOrder != null ? sortOrder : 0; - if (activityImageRepository.existsByActivityIdAndSortOrderAndIdNot( + if (imageRepository.existsByActivityIdAndSortOrderAndIdNot( activityId, normalizedSortOrder, imageId diff --git a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityPageController.java b/src/main/java/com/jjikmeok/app/domain/page/controller/PageController.java similarity index 62% rename from src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityPageController.java rename to src/main/java/com/jjikmeok/app/domain/page/controller/PageController.java index 661165c..298116a 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityPageController.java +++ b/src/main/java/com/jjikmeok/app/domain/page/controller/PageController.java @@ -1,12 +1,12 @@ -package com.jjikmeok.app.domain.activity.controller; +package com.jjikmeok.app.domain.page.controller; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCategoryPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCustomPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityHomePageResponse; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.service.ActivityPageService; +import com.jjikmeok.app.domain.page.dto.response.ActivityCategoryPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCustomPageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityDetailPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityHomePageResponse; +import com.jjikmeok.app.domain.page.service.PageService; import com.jjikmeok.app.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -19,39 +19,39 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Activity Page", description = "프론트 화면 단위 활동 API") +@Tag(name = "Page", description = "프론트 화면 단위 활동 API") @RestController @RequiredArgsConstructor -@RequestMapping("/api/v1/activities/pages") -public class ActivityPageController { +@RequestMapping("/api/v1/pages") +public class PageController { - private final ActivityPageService activityPageService; + private final PageService pageService; - @Operation(summary = "홈 화면 활동 데이터 조회", description = "홈 화면의 카테고리 바로가기, 추천 활동, 마감 임박 활동을 한 번에 조회합니다.") + @Operation(summary = "홈 화면 활동 데이터 조회", description = "홈 화면의 사용자 정보, 추천 활동, 마감 임박 활동을 조회합니다.") @GetMapping("/home") public ApiResponse getHomePage( @AuthenticationPrincipal Long userId, @Parameter(description = "섹션별 최대 활동 개수", example = "10") @RequestParam(value = "limit", required = false) Integer limit ) { - return ApiResponse.success("홈 화면 활동 조회 성공", activityPageService.getHomePage(userId, limit)); + return ApiResponse.success("홈 페이지 조회 성공", pageService.getHomePage(userId, limit)); } - @Operation(summary = "카테고리 화면 활동 데이터 조회", description = "프로그램/원데이/행사·강연/동아리 탭과 활동 카테고리 필터에 맞는 목록 화면 데이터를 조회합니다.") + @Operation(summary = "카테고리 화면 활동 데이터 조회", description = "활동 유형과 활동 카테고리 필터에 맞는 목록 화면 데이터를 조회합니다.") @GetMapping("/category") public ApiResponse getCategoryPage( @AuthenticationPrincipal Long userId, - @Parameter(description = "활동 타입 탭", example = "PROGRAM") + @Parameter(description = "활동 유형", example = "\"PROGRAM\"") @RequestParam(value = "type", required = false) ActivityType type, - @Parameter(description = "활동 카테고리 칩", example = "CRAFT") + @Parameter(description = "활동 카테고리", example = "\"CRAFT\"") @RequestParam(value = "category", required = false) ActivityCategory category, @Parameter(description = "정렬. recommended, deadline, popular", example = "recommended") @RequestParam(value = "sort", required = false) String sort, @Parameter(description = "최대 활동 개수", example = "20") @RequestParam(value = "limit", required = false) Integer limit ) { - return ApiResponse.success("카테고리 화면 활동 조회 성공", - activityPageService.getCategoryPage(userId, type, category, sort, limit)); + return ApiResponse.success("카테고리 페이지 조회 성공", + pageService.getCategoryPage(userId, type, category, sort, limit)); } @Operation(summary = "맞춤 화면 활동 데이터 조회", description = "사용자 온보딩 취향과 지역을 기준으로 맞춤 추천 화면 데이터를 조회합니다.") @@ -61,16 +61,16 @@ public ApiResponse getCustomPage( @Parameter(description = "최대 활동 개수", example = "10") @RequestParam(value = "limit", required = false) Integer limit ) { - return ApiResponse.success("맞춤 화면 활동 조회 성공", activityPageService.getCustomPage(userId, limit)); + return ApiResponse.success("맞춤 페이지 조회 성공", pageService.getCustomPage(userId, limit)); } - @Operation(summary = "상세 화면 활동 데이터 조회", description = "상세 화면 표시용 활동 정보, 이미지, 기간/가격/D-day 라벨, 찜 여부를 조회합니다.") + @Operation(summary = "상세 화면 활동 데이터 조회", description = "상세 화면에 표시할 활동 정보, 이미지, 찜 여부를 조회합니다.") @GetMapping("/detail/{activityId}") public ApiResponse getDetailPage( @AuthenticationPrincipal Long userId, @Parameter(description = "활동 ID", example = "1") @PathVariable Long activityId ) { - return ApiResponse.success("상세 화면 활동 조회 성공", activityPageService.getDetailPage(userId, activityId)); + return ApiResponse.success("상세 페이지 조회 성공", pageService.getDetailPage(userId, activityId)); } } diff --git a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityPageConverter.java b/src/main/java/com/jjikmeok/app/domain/page/converter/PageConverter.java similarity index 50% rename from src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityPageConverter.java rename to src/main/java/com/jjikmeok/app/domain/page/converter/PageConverter.java index 4d331ab..25fb7bc 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/converter/ActivityPageConverter.java +++ b/src/main/java/com/jjikmeok/app/domain/page/converter/PageConverter.java @@ -1,61 +1,62 @@ -package com.jjikmeok.app.domain.activity.converter; +package com.jjikmeok.app.domain.page.converter; import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.activity.entity.ActivityTag; -import com.jjikmeok.app.domain.image.entity.ActivityImage; +import com.jjikmeok.app.domain.activity.enums.PreferenceTag; +import com.jjikmeok.app.domain.activity.enums.PreferenceTagGroup; +import com.jjikmeok.app.domain.image.entity.Image; import com.jjikmeok.app.domain.page.dto.response.ActivityCardResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityDetailPageResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityImageItemResponse; +import com.jjikmeok.app.domain.page.dto.response.ImageItemResponse; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; +import java.util.Map; -public final class ActivityPageConverter { +public final class PageConverter { - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final int CARD_TAG_LIMIT = 2; + private static final int DETAIL_TAG_LIMIT = 3; - private ActivityPageConverter() { + private PageConverter() { } - public static ActivityCardResponse toCard(Activity activity, boolean liked, LocalDate today) { + public static ActivityCardResponse toCard(Activity activity, boolean liked, boolean isAd, LocalDate today) { Deadline deadline = deadline(activity.getRecruitEndAt(), today); return new ActivityCardResponse( activity.getId(), activity.getTitle(), activity.getThumbnailUrl(), - deadline.dDay(), deadline.daysUntilRecruitEnd(), - deadline.text(), activity.getRegion().getId(), activity.getRegion().getName(), activity.getAddress(), activity.getActivityType(), - activity.getActivityType().getLabel(), activity.getCategory(), - activity.getCategory().getLabel(), - hashtags(activity), + randomHashtags(activity, CARD_TAG_LIMIT), + isAd, activity.getPrice(), - priceLabel(activity.getPrice()), activity.getViewCount(), activity.getLikeCount(), activity.getReviewCount(), liked, activity.getStartAt(), activity.getEndAt(), + activity.getRecruitStartAt(), activity.getRecruitEndAt() ); } public static ActivityDetailPageResponse toDetail( Activity activity, - List images, + List images, boolean liked, LocalDate today ) { @@ -78,18 +79,11 @@ public static ActivityDetailPageResponse toDetail( activity.getEndAt(), activity.getRecruitStartAt(), activity.getRecruitEndAt(), - periodText(activity.getStartAt(), activity.getEndAt()), - periodText(activity.getRecruitStartAt(), activity.getRecruitEndAt()), - deadline.dDay(), deadline.daysUntilRecruitEnd(), - deadline.text(), activity.getPrice(), - priceLabel(activity.getPrice()), activity.getActivityType(), - activity.getActivityType().getLabel(), activity.getCategory(), - activity.getCategory().getLabel(), - hashtags(activity), + randomHashtags(activity, DETAIL_TAG_LIMIT), activity.getSourceType(), activity.getExternalId(), activity.getApprovalStatus(), @@ -103,41 +97,72 @@ public static ActivityDetailPageResponse toDetail( ); } - public static String priceLabel(Integer price) { - if (price == null) { - return "금액 확인"; - } - if (price == 0) { - return "무료"; - } - return String.format("%,d원", price); + private static List randomHashtags(Activity activity, int limit) { + List candidates = new ArrayList<>(tagCandidates(activity)); + Collections.shuffle(candidates); + return candidates.stream() + .distinct() + .limit(limit) + .toList(); } - public static List hashtags(Activity activity) { - Set values = new LinkedHashSet<>(); + private static List tagCandidates(Activity activity) { + Map> tagsByGroup = new EnumMap<>(PreferenceTagGroup.class); + for (ActivityTag activityTag : activity.getTags()) { + if (activityTag.getTag() == null) { + continue; + } - activity.getTags().stream() - .map(ActivityTag::getTag) - .map(tag -> hashtag(tag.getName())) - .forEach(values::add); + PreferenceTag preferenceTag = preferenceTag(activityTag.getTag().getName()); + if (preferenceTag == null) { + continue; + } - if (values.isEmpty()) { - values.add(hashtag(activity.getCategory().getLabel())); - values.add(hashtag(activity.getActivityType().getLabel())); - values.add(hashtag(priceLabel(activity.getPrice()))); + tagsByGroup.computeIfAbsent(preferenceTag.getGroup(), ignored -> new ArrayList<>()) + .add(preferenceTag.getHashtag()); } - return values.stream() + List candidates = new ArrayList<>(); + addOne(candidates, tagsByGroup, PreferenceTagGroup.MOOD); + addOne(candidates, tagsByGroup, PreferenceTagGroup.INTENSITY); + addOne(candidates, tagsByGroup, PreferenceTagGroup.PURPOSE); + addOne(candidates, tagsByGroup, PreferenceTagGroup.DURATION); + addOne(candidates, tagsByGroup, PreferenceTagGroup.SIZE); + return candidates; + } + + private static void addOne(List candidates, Map> tagsByGroup, PreferenceTagGroup group) { + List values = tagsByGroup.getOrDefault(group, List.of()).stream() .filter(value -> value != null && !value.isBlank()) - .limit(4) + .distinct() .toList(); + if (values.isEmpty()) { + return; + } + + List shuffled = new ArrayList<>(values); + Collections.shuffle(shuffled); + candidates.add(shuffled.getFirst()); + } + + private static PreferenceTag preferenceTag(String label) { + if (label == null || label.isBlank()) { + return null; + } + String normalized = label.startsWith("#") ? label.substring(1) : label; + for (PreferenceTag preferenceTag : PreferenceTag.values()) { + if (preferenceTag.getLabel().equals(normalized)) { + return preferenceTag; + } + } + return null; } - private static List imageItems(Activity activity, List images) { - List items = new ArrayList<>(); + private static List imageItems(Activity activity, List images) { + List items = new ArrayList<>(); - for (ActivityImage image : images) { - items.add(new ActivityImageItemResponse( + for (Image image : images) { + items.add(new ImageItemResponse( image.getId(), image.getImageUrl(), image.getSortOrder(), @@ -146,54 +171,25 @@ private static List imageItems(Activity activity, Lis } if (items.isEmpty() && activity.getThumbnailUrl() != null && !activity.getThumbnailUrl().isBlank()) { - items.add(new ActivityImageItemResponse(null, activity.getThumbnailUrl(), 0, true)); + items.add(new ImageItemResponse(null, activity.getThumbnailUrl(), 0, true)); } return items; } - private static String periodText(LocalDateTime startAt, LocalDateTime endAt) { - if (startAt == null && endAt == null) { - return "원문에서 확인"; - } - if (startAt == null) { - return "~ " + date(endAt); - } - if (endAt == null) { - return date(startAt) + " ~"; - } - if (startAt.toLocalDate().equals(endAt.toLocalDate())) { - return date(startAt); - } - return date(startAt) + " ~ " + date(endAt); - } - - private static String date(LocalDateTime value) { - return value == null ? "" : value.format(DATE_FORMATTER); - } - - private static String hashtag(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.startsWith("#") ? value : "#" + value; - } private static Deadline deadline(LocalDateTime recruitEndAt, LocalDate today) { if (recruitEndAt == null) { - return new Deadline("상시", null, "상시 모집"); + return new Deadline(null); + } + if (recruitEndAt.getYear() >= 2999) { + return new Deadline(null); } long days = ChronoUnit.DAYS.between(today, recruitEndAt.toLocalDate()); - if (days < 0) { - return new Deadline("마감", days, "모집 마감"); - } - if (days == 0) { - return new Deadline("D-DAY", 0L, "오늘 마감"); - } - return new Deadline("D-" + days, days, days + "일 남음"); + return new Deadline(Math.toIntExact(days)); } - private record Deadline(String dDay, Long daysUntilRecruitEnd, String text) { + private record Deadline(Integer daysUntilRecruitEnd) { } } diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCardResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCardResponse.java index c0cac61..b1956f5 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCardResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCardResponse.java @@ -10,25 +10,23 @@ public record ActivityCardResponse( Long id, String title, String thumbnailUrl, - String dDay, - Long daysUntilRecruitEnd, - String deadlineText, + Integer deadline, Long regionId, String regionName, String address, ActivityType activityType, - String activityTypeLabel, ActivityCategory category, - String categoryLabel, List hashtags, + Boolean isAd, Integer price, - String priceLabel, Integer viewCount, Integer likeCount, Integer reviewCount, Boolean liked, LocalDateTime startAt, LocalDateTime endAt, + LocalDateTime recruitStartAt, LocalDateTime recruitEndAt ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/CategoryPageResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCategoryPageResponse.java similarity index 67% rename from src/main/java/com/jjikmeok/app/domain/page/dto/response/CategoryPageResponse.java rename to src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCategoryPageResponse.java index a001e0e..c1a60b7 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/CategoryPageResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCategoryPageResponse.java @@ -2,18 +2,18 @@ import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; - import java.util.List; -public record CategoryPageResponse( +public record ActivityCategoryPageResponse( String pageTitle, ActivityType selectedType, ActivityCategory selectedCategory, String selectedSort, Long totalCount, - List typeTabs, - List categoryChips, + List typeOptions, + List categoryOptions, List sortOptions, - List activities + List activities ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/CustomPageResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCustomPageResponse.java similarity index 88% rename from src/main/java/com/jjikmeok/app/domain/page/dto/response/CustomPageResponse.java rename to src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCustomPageResponse.java index d875f1d..07ea72d 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/CustomPageResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityCustomPageResponse.java @@ -2,7 +2,7 @@ import java.util.List; -public record CustomPageResponse( +public record ActivityCustomPageResponse( String nickname, TasteProfile tasteProfile, ActivitySectionResponse recommended @@ -14,3 +14,4 @@ public record TasteProfile( ) { } } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityDetailPageResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityDetailPageResponse.java index 2f1ae7a..29a51cc 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityDetailPageResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityDetailPageResponse.java @@ -15,7 +15,7 @@ public record ActivityDetailPageResponse( String title, String description, String thumbnailUrl, - List images, + List images, String sourceUrl, String address, String organizer, @@ -25,17 +25,10 @@ public record ActivityDetailPageResponse( LocalDateTime endAt, LocalDateTime recruitStartAt, LocalDateTime recruitEndAt, - String activityPeriodText, - String recruitPeriodText, - String dDay, - Long daysUntilRecruitEnd, - String deadlineText, + Integer deadline, Integer price, - String priceLabel, ActivityType activityType, - String activityTypeLabel, ActivityCategory category, - String categoryLabel, List hashtags, SourceType sourceType, String externalId, @@ -49,3 +42,4 @@ public record ActivityDetailPageResponse( LocalDateTime updatedAt ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityFilterOptionResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityFilterOptionResponse.java index 746c038..bae4b24 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityFilterOptionResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityFilterOptionResponse.java @@ -6,3 +6,4 @@ public record ActivityFilterOptionResponse( Boolean selected ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityHomePageResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityHomePageResponse.java new file mode 100644 index 0000000..8ad3cc0 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityHomePageResponse.java @@ -0,0 +1,16 @@ +package com.jjikmeok.app.domain.page.dto.response; + +import java.util.List; + +public record ActivityHomePageResponse( + UserResponse user, + List recommendedActivities, + List closingSoonActivities +) { + public record UserResponse( + String nickname, + String profileImageUrl + ) { + } +} + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityListItemResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityListItemResponse.java deleted file mode 100644 index 593818d..0000000 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityListItemResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.jjikmeok.app.domain.page.dto.response; - -import java.util.List; - -public record ActivityListItemResponse( - Long id, - String title, - String thumbnailUrl, - String dDay, - Integer viewCount, - Integer likeCount, - List hashtags, - Boolean liked -) { -} diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivitySectionResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivitySectionResponse.java index 9ba07c4..bdaf27a 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivitySectionResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivitySectionResponse.java @@ -9,3 +9,4 @@ public record ActivitySectionResponse( List activities ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityShortcutResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityShortcutResponse.java index 05771c9..96285d5 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityShortcutResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityShortcutResponse.java @@ -9,3 +9,4 @@ public record ActivityShortcutResponse( String href ) { } + diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/HomePageResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/HomePageResponse.java deleted file mode 100644 index b3b5ef3..0000000 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/HomePageResponse.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.jjikmeok.app.domain.page.dto.response; - -import java.util.List; - -public record HomePageResponse( - String nickname, - Banner banner, - List shortcuts, - RecommendedSection recommended, - ClosingSoonSection closingSoon -) { - public record Banner( - String title, - String subtitle, - String actionLabel, - String actionHref, - Integer currentIndex, - Integer totalCount - ) { - } - - public record RecommendedSection( - String title, - String actionHref, - List activities - ) { - } - - public record ClosingSoonSection( - String title, - String actionHref, - String theme, - List activities - ) { - } - - public record RecommendedActivity( - Long id, - String title, - String thumbnailUrl, - String categoryLabel, - String dDay, - List hashtags, - Boolean liked - ) { - } - - public record ClosingSoonActivity( - Long id, - String title, - String summary, - String thumbnailUrl, - String dDay, - String badgeLabel, - String categoryLabel, - Boolean liked - ) { - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityImageItemResponse.java b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ImageItemResponse.java similarity index 78% rename from src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityImageItemResponse.java rename to src/main/java/com/jjikmeok/app/domain/page/dto/response/ImageItemResponse.java index d6cbfce..7d640be 100644 --- a/src/main/java/com/jjikmeok/app/domain/page/dto/response/ActivityImageItemResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/page/dto/response/ImageItemResponse.java @@ -1,6 +1,6 @@ package com.jjikmeok.app.domain.page.dto.response; -public record ActivityImageItemResponse( +public record ImageItemResponse( Long id, String imageUrl, Integer sortOrder, diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageService.java b/src/main/java/com/jjikmeok/app/domain/page/service/PageService.java similarity index 64% rename from src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageService.java rename to src/main/java/com/jjikmeok/app/domain/page/service/PageService.java index 6d77220..97543f7 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageService.java +++ b/src/main/java/com/jjikmeok/app/domain/page/service/PageService.java @@ -1,13 +1,13 @@ -package com.jjikmeok.app.domain.activity.service; +package com.jjikmeok.app.domain.page.service; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCategoryPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCustomPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityHomePageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCategoryPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCustomPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityHomePageResponse; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; import com.jjikmeok.app.domain.page.dto.response.ActivityDetailPageResponse; -public interface ActivityPageService { +public interface PageService { ActivityHomePageResponse getHomePage(Long userId, Integer limit); @@ -23,3 +23,4 @@ ActivityCategoryPageResponse getCategoryPage( ActivityDetailPageResponse getDetailPage(Long userId, Long activityId); } + diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/page/service/PageServiceImpl.java similarity index 72% rename from src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageServiceImpl.java rename to src/main/java/com/jjikmeok/app/domain/page/service/PageServiceImpl.java index f36e261..2d1c55e 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityPageServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/page/service/PageServiceImpl.java @@ -1,22 +1,21 @@ -package com.jjikmeok.app.domain.activity.service; +package com.jjikmeok.app.domain.page.service; -import com.jjikmeok.app.domain.activity.converter.ActivityPageConverter; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCategoryPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCustomPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityHomePageResponse; import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; -import com.jjikmeok.app.domain.activity.repository.ActivityFavoriteRepository; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; -import com.jjikmeok.app.domain.image.entity.ActivityImage; -import com.jjikmeok.app.domain.image.repository.ActivityImageRepository; +import com.jjikmeok.app.domain.favorite.repository.FavoriteRepository; +import com.jjikmeok.app.domain.image.entity.Image; +import com.jjikmeok.app.domain.image.repository.ImageRepository; +import com.jjikmeok.app.domain.page.converter.PageConverter; import com.jjikmeok.app.domain.page.dto.response.ActivityCardResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCategoryPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCustomPageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityDetailPageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityFilterOptionResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityHomePageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivitySectionResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityShortcutResponse; import com.jjikmeok.app.domain.user.entity.UserOnboardingTag; import com.jjikmeok.app.domain.user.repository.UserOnboardingRegionRepository; import com.jjikmeok.app.domain.user.repository.UserOnboardingTagRepository; @@ -40,7 +39,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ActivityPageServiceImpl implements ActivityPageService { +public class PageServiceImpl implements PageService { private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul"); private static final int DEFAULT_HOME_LIMIT = 10; @@ -50,8 +49,8 @@ public class ActivityPageServiceImpl implements ActivityPageService { private static final ApprovalStatus PUBLIC_STATUS = ApprovalStatus.APPROVED; private final ActivityRepository activityRepository; - private final ActivityFavoriteRepository activityFavoriteRepository; - private final ActivityImageRepository activityImageRepository; + private final FavoriteRepository favoriteRepository; + private final ImageRepository imageRepository; private final UserProfileRepository userProfileRepository; private final UserOnboardingTagRepository userOnboardingTagRepository; private final UserOnboardingRegionRepository userOnboardingRegionRepository; @@ -59,7 +58,7 @@ public class ActivityPageServiceImpl implements ActivityPageService { @Override public ActivityHomePageResponse getHomePage(Long userId, Integer limit) { int size = limit(limit, DEFAULT_HOME_LIMIT); - String nickname = nickname(userId); + ActivityHomePageResponse.UserResponse user = homeUser(userId); List recommended = cards(userId, recommendedActivities(userId, size), size); List closingSoon = cards( @@ -68,18 +67,7 @@ public ActivityHomePageResponse getHomePage(Long userId, Integer limit) { size ); - return new ActivityHomePageResponse( - nickname, - new ActivityHomePageResponse.Hero( - nickname + "님, 오늘은 뭐 찍먹해볼까요?", - "확신이 없어도 괜찮아요. 맞는 활동부터 가볍게 둘러보세요.", - "나만의 경험 탐색하기", - "/activities/pages/custom" - ), - shortcuts(), - new ActivitySectionResponse("recommended", nickname + "님에게 추천해요!", null, recommended), - new ActivitySectionResponse("closingSoon", "인기 마감 임박", "신청 마감이 가까운 활동이에요.", closingSoon) - ); + return new ActivityHomePageResponse(user, recommended, closingSoon); } @Override @@ -107,13 +95,13 @@ public ActivityCategoryPageResponse getCategoryPage( long totalCount = activityRepository.countApprovedActivitiesByFilters(PUBLIC_STATUS, category, type, now); return new ActivityCategoryPageResponse( - type == null ? "카테고리" : type.getLabel(), + type == null ? "전체" : type.getLabel(), type, category, selectedSort, totalCount, - typeTabs(type), - categoryChips(category), + typeOptions(type), + categoryOptions(category), sortOptions(selectedSort), cards(userId, sorted, size) ); @@ -134,7 +122,7 @@ public ActivityCustomPageResponse getCustomPage(Long userId, Integer limit) { nickname, new ActivityCustomPageResponse.TasteProfile( tasteTitle(hashtags), - nickname + "님과 취향 적합도가 높은 활동을 모아봤어요!", + nickname + "님의 취향에 맞는 활동을 모아봤어요.", hashtags ), new ActivitySectionResponse("customRecommended", "맞춤 추천 활동", null, recommended) @@ -152,10 +140,10 @@ public ActivityDetailPageResponse getDetailPage(Long userId, Long activityId) { Activity activity = activityRepository.findApprovedByIdWithRegion(activityId, PUBLIC_STATUS, recruitCutoff) .orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_NOT_FOUND)); - List images = activityImageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(activityId); - boolean liked = userId != null && activityFavoriteRepository.existsByUserIdAndActivityId(userId, activityId); + List images = imageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(activityId); + boolean liked = userId != null && favoriteRepository.existsByUserIdAndActivityId(userId, activityId); - return ActivityPageConverter.toDetail(activity, images, liked, LocalDate.now(SEOUL)); + return PageConverter.toDetail(activity, images, liked, LocalDate.now(SEOUL)); } private List recommendedActivities(Long userId, int size) { @@ -197,13 +185,36 @@ private List cards(Long userId, List activities, .limit(limit) .toList(); Set likedActivityIds = likedActivityIds(userId, distinctActivities); + Set adActivityIds = adActivityIds(distinctActivities); LocalDate today = LocalDate.now(SEOUL); return distinctActivities.stream() - .map(activity -> ActivityPageConverter.toCard(activity, likedActivityIds.contains(activity.getId()), today)) + .map(activity -> PageConverter.toCard( + activity, + likedActivityIds.contains(activity.getId()), + adActivityIds.contains(activity.getId()), + today + )) .toList(); } + private Set adActivityIds(List activities) { + List ranked = activities.stream() + .sorted(Comparator + .comparing(Activity::getViewCount, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Activity::getCreatedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .limit(3) + .toList(); + + if (ranked.isEmpty()) { + return Set.of(); + } + + List shuffled = new java.util.ArrayList<>(ranked); + java.util.Collections.shuffle(shuffled); + return Set.of(shuffled.getFirst().getId()); + } + private Set likedActivityIds(Long userId, List activities) { if (userId == null || activities.isEmpty()) { return Set.of(); @@ -213,7 +224,7 @@ private Set likedActivityIds(Long userId, List activities) { .map(Activity::getId) .toList(); - return Set.copyOf(activityFavoriteRepository.findActivityIdsByUserIdAndActivityIdIn(userId, activityIds)); + return Set.copyOf(favoriteRepository.findActivityIdsByUserIdAndActivityIdIn(userId, activityIds)); } private List distinct(List activities) { @@ -261,11 +272,24 @@ private int limit(Integer requested, int defaultValue) { private String nickname(Long userId) { if (userId == null) { - return "닉네임"; + return "게스트"; } return userProfileRepository.findByUserId(userId) .map(userProfile -> userProfile.getNickname()) - .orElse("닉네임"); + .orElse("게스트"); + } + + private ActivityHomePageResponse.UserResponse homeUser(Long userId) { + if (userId == null) { + return new ActivityHomePageResponse.UserResponse("게스트", ""); + } + + return userProfileRepository.findByUserId(userId) + .map(userProfile -> new ActivityHomePageResponse.UserResponse( + userProfile.getNickname(), + userProfile.getProfileImageUrl() + )) + .orElseGet(() -> new ActivityHomePageResponse.UserResponse("게스트", "")); } private List preferenceTags(Long userId) { @@ -277,30 +301,21 @@ private List preferenceTags(Long userId) { private String tasteTitle(List hashtags) { if (hashtags.isEmpty()) { - return "우선 한 입만 먹어보는 형"; + return "아직 선호 정보가 부족해요"; } - if (hashtags.contains("#도전") || hashtags.contains("#몰입")) { - return "꽂히면 깊게 파보는 형"; + if (hashtags.stream().anyMatch(tag -> tag.contains("운동") || tag.contains("액티비티") || tag.contains("등산"))) { + return "활동적인 취향이네요"; } - if (hashtags.contains("#사교")) { - return "같이 하면 더 즐거운 형"; + if (hashtags.stream().anyMatch(tag -> tag.contains("모임") || tag.contains("스터디") || tag.contains("클럽"))) { + return "함께하는 활동을 좋아하시네요"; } - if (hashtags.contains("#힐링") || hashtags.contains("#휴식")) { - return "천천히 충전하는 형"; + if (hashtags.stream().anyMatch(tag -> tag.contains("클래스") || tag.contains("교육") || tag.contains("강연"))) { + return "배움이 있는 활동을 선호하시네요"; } - return "우선 한 입만 먹어보는 형"; - } - - private List shortcuts() { - return List.of( - new ActivityShortcutResponse(ActivityType.PROGRAM, ActivityType.PROGRAM.getLabel(), "palette", "/activities/pages/category?type=PROGRAM"), - new ActivityShortcutResponse(ActivityType.ONE_DAY, ActivityType.ONE_DAY.getLabel(), "clock", "/activities/pages/category?type=ONE_DAY"), - new ActivityShortcutResponse(ActivityType.EVENT, ActivityType.EVENT.getLabel(), "megaphone", "/activities/pages/category?type=EVENT"), - new ActivityShortcutResponse(ActivityType.CLUB, ActivityType.CLUB.getLabel(), "users", "/activities/pages/category?type=CLUB") - ); + return "아직 선호 정보가 부족해요"; } - private List typeTabs(ActivityType selectedType) { + private List typeOptions(ActivityType selectedType) { List options = new java.util.ArrayList<>(); options.add(new ActivityFilterOptionResponse("", "전체", selectedType == null)); for (ActivityType type : ActivityType.values()) { @@ -309,7 +324,7 @@ private List typeTabs(ActivityType selectedType) { return options; } - private List categoryChips(ActivityCategory selectedCategory) { + private List categoryOptions(ActivityCategory selectedCategory) { List options = new java.util.ArrayList<>(); options.add(new ActivityFilterOptionResponse("", "전체", selectedCategory == null)); for (ActivityCategory category : ActivityCategory.values()) { @@ -321,8 +336,8 @@ private List categoryChips(ActivityCategory select private List sortOptions(String selectedSort) { return List.of( new ActivityFilterOptionResponse("recommended", "추천순", "recommended".equals(selectedSort)), - new ActivityFilterOptionResponse("deadline", "마감임박순", "deadline".equals(selectedSort)), - new ActivityFilterOptionResponse("popular", "인기순", "popular".equals(selectedSort)) + new ActivityFilterOptionResponse("popular", "인기순", "popular".equals(selectedSort)), + new ActivityFilterOptionResponse("deadline", "마감순", "deadline".equals(selectedSort)) ); } } diff --git a/src/main/java/com/jjikmeok/app/domain/personalization/service/PersonlizationService.java b/src/main/java/com/jjikmeok/app/domain/personalization/service/PersonlizationService.java index d214f49..ebf8623 100644 --- a/src/main/java/com/jjikmeok/app/domain/personalization/service/PersonlizationService.java +++ b/src/main/java/com/jjikmeok/app/domain/personalization/service/PersonlizationService.java @@ -5,7 +5,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -30,43 +37,19 @@ public PersonalizationResponse findBestType(Long userId) { .filter(Objects::nonNull) .map(tag -> tag.replace("#", "").trim()) .filter(tag -> !tag.isBlank()) - .collect(java.util.stream.Collectors.toSet()); + .collect(Collectors.toSet()); if (userTagSet.isEmpty()) { return new PersonalizationResponse("분류 불가", pickRandomDisplayTags(userTags)); } Map> typeTags = new LinkedHashMap<>(); - - typeTags.put("우선 한입만 먹어보는 형", - List.of("입문", "가볍게", "단기", "취미")); - - typeTags.put("부담 없는 것부터 고르는 형", - List.of("편안한", "힐링", "휴식", "가볍게", "단기")); - - typeTags.put("천천히 음미하는 형", - List.of("편안한", "감성적", "취미", "배움", "한달")); - - typeTags.put("제대로 맛보고 싶은 형", - List.of("몰입", "도전", "배움", "성장", "한달")); - - typeTags.put("여러 가지 취향껏 골라먹는 형", - List.of("활기찬", "트렌디", "취미", "입문", "가볍게", "단기")); - - typeTags.put("같이 먹어야 더 맛있는 형", - List.of("활기찬", "취미", "소규모", "대규모")); - - typeTags.put("꽂히면 계속 먹는 형", - List.of("몰입", "취미", "성장", "한달", "6개월")); - - typeTags.put("새로운 맛에 끌리는 형", - List.of("활기찬", "트렌디", "도전", "입문", "가볍게", "단기")); - - typeTags.put("스테디한 맛을 좋아하는 형", - List.of("편안한", "휴식", "취미", "가볍게", "6개월")); - - typeTags.put("끝까지 맛보는 형", - List.of("몰입", "도전", "배움", "성장", "6개월", "1년이상")); + typeTags.put("편안하게 쉬는 타입", List.of("편안한", "힐링", "휴식", "가볍게", "단기")); + typeTags.put("감성 충전 타입", List.of("감성적", "창의적", "취미", "배움", "한달")); + typeTags.put("배움 성장 타입", List.of("입문", "몰입", "배움", "성장", "한달")); + typeTags.put("활기 도전 타입", List.of("활기찬", "트렌디", "도전", "입문", "단기")); + typeTags.put("소규모 몰입 타입", List.of("몰입", "가볍게", "소규모", "단기")); + typeTags.put("대규모 트렌드 타입", List.of("활기찬", "트렌디", "대규모", "취미")); String bestType = "분류 불가"; long bestMatchCount = 0; diff --git a/src/main/java/com/jjikmeok/app/domain/region/controller/RegionController.java b/src/main/java/com/jjikmeok/app/domain/region/controller/RegionController.java index 2cf03e0..2dffd46 100644 --- a/src/main/java/com/jjikmeok/app/domain/region/controller/RegionController.java +++ b/src/main/java/com/jjikmeok/app/domain/region/controller/RegionController.java @@ -94,7 +94,7 @@ public ApiResponse createRegion( @RequestBody @Valid RegionRequest request) { RegionResponse response = regionService.createRegion(request); - return ApiResponse.success("지역 생성 성공", response); + return ApiResponse.created("지역 생성 성공", response); } @Operation(summary = "지역 수정 (관리자)", description = "특정 지역 정보를 수정합니다.") diff --git a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityReviewController.java b/src/main/java/com/jjikmeok/app/domain/review/controller/ReviewController.java similarity index 58% rename from src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityReviewController.java rename to src/main/java/com/jjikmeok/app/domain/review/controller/ReviewController.java index 8a61fbe..b5c3158 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/controller/ActivityReviewController.java +++ b/src/main/java/com/jjikmeok/app/domain/review/controller/ReviewController.java @@ -1,8 +1,8 @@ -package com.jjikmeok.app.domain.activity.controller; +package com.jjikmeok.app.domain.review.controller; -import com.jjikmeok.app.domain.activity.dto.request.ActivityReviewRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityReviewResponse; -import com.jjikmeok.app.domain.activity.service.ActivityReviewService; +import com.jjikmeok.app.domain.review.dto.request.ReviewRequest; +import com.jjikmeok.app.domain.review.dto.response.ReviewResponse; +import com.jjikmeok.app.domain.review.service.ReviewService; import com.jjikmeok.app.global.common.response.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -24,49 +24,56 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Activity Review", description = "활동 후기 API") +@Tag(name = "리뷰 API", description = "활동 리뷰 관리 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/activities/{activityId}/reviews") -public class ActivityReviewController { +public class ReviewController { - private final ActivityReviewService reviewService; + private final ReviewService reviewService; - @Operation(summary = "활동 후기 목록 조회") + @Operation(summary = "리뷰 목록 조회") @GetMapping - public ApiResponse> getReviews( + public ApiResponse> getReviews( @PathVariable("activityId") Long activityId, - @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { - return ApiResponse.success("활동 후기 목록 조회 성공", reviewService.getReviews(activityId, pageable)); + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ApiResponse.success("리뷰 목록 조회 성공", reviewService.getReviews(activityId, pageable)); } - @Operation(summary = "활동 후기 작성") + @Operation(summary = "리뷰 생성") @PostMapping @ResponseStatus(HttpStatus.CREATED) - public ApiResponse createReview( + public ApiResponse createReview( @AuthenticationPrincipal Long userId, @PathVariable("activityId") Long activityId, - @RequestBody @Valid ActivityReviewRequest request) { - return ApiResponse.success("활동 후기 작성 성공", reviewService.createReview(userId, activityId, request)); + @RequestBody @Valid ReviewRequest request + ) { + return ApiResponse.created("리뷰 생성 성공", reviewService.createReview(userId, activityId, request)); } - @Operation(summary = "활동 후기 수정") + @Operation(summary = "리뷰 수정") @PutMapping("/{reviewId}") - public ApiResponse updateReview( + public ApiResponse updateReview( @AuthenticationPrincipal Long userId, @PathVariable("activityId") Long activityId, @PathVariable("reviewId") Long reviewId, - @RequestBody @Valid ActivityReviewRequest request) { - return ApiResponse.success("활동 후기 수정 성공", reviewService.updateReview(userId, activityId, reviewId, request)); + @RequestBody @Valid ReviewRequest request + ) { + return ApiResponse.success( + "리뷰 수정 성공", + reviewService.updateReview(userId, activityId, reviewId, request) + ); } - @Operation(summary = "활동 후기 삭제") + @Operation(summary = "리뷰 삭제") @DeleteMapping("/{reviewId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteReview( @AuthenticationPrincipal Long userId, @PathVariable("activityId") Long activityId, - @PathVariable("reviewId") Long reviewId) { + @PathVariable("reviewId") Long reviewId + ) { reviewService.deleteReview(userId, activityId, reviewId); } } diff --git a/src/main/java/com/jjikmeok/app/domain/review/converter/ReviewConverter.java b/src/main/java/com/jjikmeok/app/domain/review/converter/ReviewConverter.java new file mode 100644 index 0000000..9b81009 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/review/converter/ReviewConverter.java @@ -0,0 +1,23 @@ +package com.jjikmeok.app.domain.review.converter; + +import com.jjikmeok.app.domain.review.dto.response.ReviewResponse; +import com.jjikmeok.app.domain.review.entity.Review; + +public class ReviewConverter { + + private ReviewConverter() { + } + + public static ReviewResponse toResponse(Review review) { + return new ReviewResponse( + review.getId(), + review.getUser().getId(), + review.getActivity().getId(), + review.getRating(), + review.getReason(), + review.getLikeCount(), + review.getCreatedAt(), + review.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityReviewRequest.java b/src/main/java/com/jjikmeok/app/domain/review/dto/request/ReviewRequest.java similarity index 81% rename from src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityReviewRequest.java rename to src/main/java/com/jjikmeok/app/domain/review/dto/request/ReviewRequest.java index 72d12eb..ce3244d 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/request/ActivityReviewRequest.java +++ b/src/main/java/com/jjikmeok/app/domain/review/dto/request/ReviewRequest.java @@ -1,10 +1,10 @@ -package com.jjikmeok.app.domain.activity.dto.request; +package com.jjikmeok.app.domain.review.dto.request; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; -public record ActivityReviewRequest( +public record ReviewRequest( @NotNull(message = "별점은 필수입니다.") @Min(value = 1, message = "별점은 1 이상이어야 합니다.") @Max(value = 5, message = "별점은 5 이하여야 합니다.") diff --git a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityReviewResponse.java b/src/main/java/com/jjikmeok/app/domain/review/dto/response/ReviewResponse.java similarity index 72% rename from src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityReviewResponse.java rename to src/main/java/com/jjikmeok/app/domain/review/dto/response/ReviewResponse.java index be11f9f..9e12fbd 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/dto/response/ActivityReviewResponse.java +++ b/src/main/java/com/jjikmeok/app/domain/review/dto/response/ReviewResponse.java @@ -1,8 +1,8 @@ -package com.jjikmeok.app.domain.activity.dto.response; +package com.jjikmeok.app.domain.review.dto.response; import java.time.LocalDateTime; -public record ActivityReviewResponse( +public record ReviewResponse( Long id, Long userId, Long activityId, diff --git a/src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityReview.java b/src/main/java/com/jjikmeok/app/domain/review/entity/Review.java similarity index 81% rename from src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityReview.java rename to src/main/java/com/jjikmeok/app/domain/review/entity/Review.java index fb98f59..00a2160 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/entity/ActivityReview.java +++ b/src/main/java/com/jjikmeok/app/domain/review/entity/Review.java @@ -1,12 +1,12 @@ -package com.jjikmeok.app.domain.activity.entity; +package com.jjikmeok.app.domain.review.entity; +import com.jjikmeok.app.domain.activity.entity.Activity; import com.jjikmeok.app.domain.user.entity.User; import com.jjikmeok.app.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; -import jakarta.persistence.Lob; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; @@ -20,7 +20,7 @@ @UniqueConstraint(name = "uk_activity_reviews_user_activity", columnNames = {"user_id", "activity_id"}) }) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ActivityReview extends BaseEntity { +public class Review extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @@ -33,14 +33,14 @@ public class ActivityReview extends BaseEntity { @Column(nullable = false) private Integer rating; - @Lob + @Column(columnDefinition = "TEXT") private String reason; @Column(name = "like_count", nullable = false) private Integer likeCount; - public static ActivityReview create(User user, Activity activity, Integer rating, String reason) { - ActivityReview review = new ActivityReview(); + public static Review create(User user, Activity activity, Integer rating, String reason) { + Review review = new Review(); review.user = user; review.activity = activity; review.rating = rating; diff --git a/src/main/java/com/jjikmeok/app/domain/review/repository/ReviewRepository.java b/src/main/java/com/jjikmeok/app/domain/review/repository/ReviewRepository.java new file mode 100644 index 0000000..12acd8b --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/review/repository/ReviewRepository.java @@ -0,0 +1,14 @@ +package com.jjikmeok.app.domain.review.repository; + +import com.jjikmeok.app.domain.review.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ReviewRepository extends JpaRepository { + Page findAllByActivityId(Long activityId, Pageable pageable); + Optional findByIdAndActivityIdAndUserId(Long id, Long activityId, Long userId); + boolean existsByUserIdAndActivityId(Long userId, Long activityId); +} diff --git a/src/main/java/com/jjikmeok/app/domain/review/service/ReviewService.java b/src/main/java/com/jjikmeok/app/domain/review/service/ReviewService.java new file mode 100644 index 0000000..cb6afa2 --- /dev/null +++ b/src/main/java/com/jjikmeok/app/domain/review/service/ReviewService.java @@ -0,0 +1,13 @@ +package com.jjikmeok.app.domain.review.service; + +import com.jjikmeok.app.domain.review.dto.request.ReviewRequest; +import com.jjikmeok.app.domain.review.dto.response.ReviewResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ReviewService { + Page getReviews(Long activityId, Pageable pageable); + ReviewResponse createReview(Long userId, Long activityId, ReviewRequest request); + ReviewResponse updateReview(Long userId, Long activityId, Long reviewId, ReviewRequest request); + void deleteReview(Long userId, Long activityId, Long reviewId); +} diff --git a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/review/service/ReviewServiceImpl.java similarity index 66% rename from src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewServiceImpl.java rename to src/main/java/com/jjikmeok/app/domain/review/service/ReviewServiceImpl.java index 705a431..3c9934c 100644 --- a/src/main/java/com/jjikmeok/app/domain/activity/service/ActivityReviewServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/review/service/ReviewServiceImpl.java @@ -1,12 +1,12 @@ -package com.jjikmeok.app.domain.activity.service; +package com.jjikmeok.app.domain.review.service; -import com.jjikmeok.app.domain.activity.converter.ActivityReviewConverter; -import com.jjikmeok.app.domain.activity.dto.request.ActivityReviewRequest; -import com.jjikmeok.app.domain.activity.dto.response.ActivityReviewResponse; import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.activity.entity.ActivityReview; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; -import com.jjikmeok.app.domain.activity.repository.ActivityReviewRepository; +import com.jjikmeok.app.domain.review.converter.ReviewConverter; +import com.jjikmeok.app.domain.review.dto.request.ReviewRequest; +import com.jjikmeok.app.domain.review.dto.response.ReviewResponse; +import com.jjikmeok.app.domain.review.entity.Review; +import com.jjikmeok.app.domain.review.repository.ReviewRepository; import com.jjikmeok.app.domain.user.entity.User; import com.jjikmeok.app.domain.user.repository.UserRepository; import com.jjikmeok.app.global.common.exception.CustomException; @@ -21,22 +21,22 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class ActivityReviewServiceImpl implements ActivityReviewService { +public class ReviewServiceImpl implements ReviewService { - private final ActivityReviewRepository reviewRepository; + private final ReviewRepository reviewRepository; private final ActivityRepository activityRepository; private final UserRepository userRepository; @Override - public Page getReviews(Long activityId, Pageable pageable) { + public Page getReviews(Long activityId, Pageable pageable) { findActivityOrThrow(activityId); return reviewRepository.findAllByActivityId(activityId, pageable) - .map(ActivityReviewConverter::toResponse); + .map(ReviewConverter::toResponse); } @Override @Transactional - public ActivityReviewResponse createReview(Long userId, Long activityId, ActivityReviewRequest request) { + public ReviewResponse createReview(Long userId, Long activityId, ReviewRequest request) { validateRating(request.rating()); User user = findUserOrThrow(userId); Activity activity = findActivityOrThrow(activityId); @@ -44,11 +44,11 @@ public ActivityReviewResponse createReview(Long userId, Long activityId, Activit throw new CustomException(ErrorCode.ACTIVITY_REVIEW_DUPLICATE); } try { - ActivityReview review = reviewRepository.save( - ActivityReview.create(user, activity, request.rating(), request.reason()) + Review review = reviewRepository.save( + Review.create(user, activity, request.rating(), request.reason()) ); activity.increaseReviewCount(); - return ActivityReviewConverter.toResponse(review); + return ReviewConverter.toResponse(review); } catch (DataIntegrityViolationException e) { throw new CustomException(ErrorCode.ACTIVITY_REVIEW_DUPLICATE); } @@ -56,13 +56,13 @@ public ActivityReviewResponse createReview(Long userId, Long activityId, Activit @Override @Transactional - public ActivityReviewResponse updateReview(Long userId, Long activityId, Long reviewId, ActivityReviewRequest request) { + public ReviewResponse updateReview(Long userId, Long activityId, Long reviewId, ReviewRequest request) { validateRating(request.rating()); findUserOrThrow(userId); findActivityOrThrow(activityId); - ActivityReview review = findReviewOrThrow(userId, activityId, reviewId); + Review review = findReviewOrThrow(userId, activityId, reviewId); review.update(request.rating(), request.reason()); - return ActivityReviewConverter.toResponse(review); + return ReviewConverter.toResponse(review); } @Override @@ -70,12 +70,12 @@ public ActivityReviewResponse updateReview(Long userId, Long activityId, Long re public void deleteReview(Long userId, Long activityId, Long reviewId) { findUserOrThrow(userId); Activity activity = findActivityOrThrow(activityId); - ActivityReview review = findReviewOrThrow(userId, activityId, reviewId); + Review review = findReviewOrThrow(userId, activityId, reviewId); reviewRepository.delete(review); activity.decreaseReviewCount(); } - private ActivityReview findReviewOrThrow(Long userId, Long activityId, Long reviewId) { + private Review findReviewOrThrow(Long userId, Long activityId, Long reviewId) { return reviewRepository.findByIdAndActivityIdAndUserId(reviewId, activityId, userId) .orElseThrow(() -> new CustomException(ErrorCode.ACTIVITY_REVIEW_NOT_FOUND)); } diff --git a/src/main/java/com/jjikmeok/app/domain/sync/controller/ActivitySyncController.java b/src/main/java/com/jjikmeok/app/domain/sync/controller/ActivitySyncController.java deleted file mode 100644 index 9df90e2..0000000 --- a/src/main/java/com/jjikmeok/app/domain/sync/controller/ActivitySyncController.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.jjikmeok.app.domain.sync.controller; - -import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.sync.dto.ActivitySyncResponse; -import com.jjikmeok.app.domain.sync.service.ActivitySyncService; -import com.jjikmeok.app.global.common.response.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -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; - -@Tag(name = "Activity Sync", description = "관리자 외부 활동 동기화 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/admin/activity-sync") -@PreAuthorize("hasRole('ADMIN')") -public class ActivitySyncController { - - private final ActivitySyncService activitySyncService; - - @Operation(summary = "🔄 [마스터 대량 동기화] 5대 오픈 API 일괄 연동 구동", description = "자바 1차 가드로 전수 필드를 검증하여 유실된 부실 데이터만 선별적으로 AI 스크래핑 파이프라인에 주입해 요금을 대폭 아낍니다.") - @PostMapping("/sync-all-sources") - public ApiResponse syncAllSources() { - activitySyncService.syncAllSources(); - return ApiResponse.success("비용 최적화 5대 오픈 API 마스터 파이프라인 가동 성공", "서버 백그라운드 콘솔 로그를 확인하세요."); - } - - @Operation(summary = "Tour API 활동 동기화") - @PostMapping("/tour-api") - public ApiResponse syncTourApi(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.TOUR_API, maxPages); - } - - @Operation(summary = "KOPIS 활동 동기화") - @PostMapping("/kopis") - public ApiResponse syncKopis(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.KOPIS, maxPages); - } - - @Operation(summary = "전시 API 활동 동기화") - @PostMapping("/exhibition") - public ApiResponse syncExhibition(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.EXHIBITION, maxPages); - } - - @Operation(summary = "1365 봉사 활동 동기화") - @PostMapping("/volunteer-1365") - public ApiResponse syncVolunteer1365(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.VOLUNTEER_1365, maxPages); - } - - @Operation(summary = "청년 콘텐츠 동기화") - @PostMapping("/youth-content") - public ApiResponse syncYouthContent(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.YOUTH_CONTENT, maxPages); - } - - @Operation(summary = "서울 문화행사 동기화") - @PostMapping("/seoul-culture") - public ApiResponse syncSeoulCulture(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.SEOUL_CULTURE, maxPages); - } - - @Operation(summary = "서울 공공예약 동기화") - @PostMapping("/seoul-reservation") - public ApiResponse syncSeoulReservation(@RequestParam(required = false) Integer maxPages) { - return sync(SourceType.SEOUL_RESERVATION, maxPages); - } - - private ApiResponse sync(SourceType sourceType, Integer maxPages) { - return ApiResponse.success("활동 동기화 성공", activitySyncService.sync(sourceType, null, maxPages)); - } -} \ No newline at end of file diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncScheduler.java b/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncScheduler.java deleted file mode 100644 index d37ad65..0000000 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/ActivitySyncScheduler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.jjikmeok.app.domain.sync.service; - -import com.jjikmeok.app.domain.activity.enums.SourceType; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ActivitySyncScheduler { - - private final ActivitySyncService activitySyncService; - - @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") - public void syncDailySources() { - log.info("⏰ [정기 스케줄러] 7개 외부 데이터 API 통합 일일 배치 동기화를 시작합니다."); - long startTime = System.currentTimeMillis(); // 전체 배치 시작 시간 측정 - - sync(SourceType.TOUR_API, SourceType.KOPIS, SourceType.EXHIBITION, SourceType.YOUTH_CONTENT, - SourceType.SEOUL_CULTURE, SourceType.VOLUNTEER_1365, SourceType.SEOUL_RESERVATION); - - long duration = System.currentTimeMillis() - startTime; // 전체 배치 종료 시간 계산 - log.info("🏁 [정기 스케줄러 종료] 전체 API 동기화 스케줄이 무사히 완료되었습니다. 총 소요시간: {}ms", duration); - } - - private void sync(SourceType... sourceTypes) { - for (SourceType sourceType : sourceTypes) { - try { - // 🌟 1. 개별 API 수집 시작 로그 추가 - log.info("🔄 [{}] 데이터 동기화 파이프라인 프로세싱 가동...", sourceType); - - activitySyncService.sync(sourceType, null); - - // 🌟 2. 개별 API 수집 성공 로그 추가 - log.info("✅ [{}] 동기화 프로세스 안전 완료", sourceType); - } catch (Exception e) { - // 예외 격리 구조 유지 및 스택 트레이스 전체 출력하도록 수정 - log.error("❌ 활동 동기화 작업 치명적 실패. 수집출처={}, 에러내용={}", sourceType, e.getMessage(), e); - } - } - } -} \ No newline at end of file diff --git a/src/main/java/com/jjikmeok/app/domain/sync/service/CategoryClassifier.java b/src/main/java/com/jjikmeok/app/domain/sync/service/CategoryClassifier.java deleted file mode 100644 index 800d34a..0000000 --- a/src/main/java/com/jjikmeok/app/domain/sync/service/CategoryClassifier.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.jjikmeok.app.domain.sync.service; - -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.enums.SourceType; -import org.springframework.stereotype.Component; -import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; - -@Component -public class CategoryClassifier { - - private static final int INVALID_FUTURE_YEAR = 2099; - - public ActivityCategory classifyCategory(SourceType sourceType, String text) { - String value = text == null ? "" : text; - - if (sourceType == SourceType.VOLUNTEER_1365 || contains(value, "봉사", "자원봉사")) return ActivityCategory.VOLUNTEER; - - if (sourceType == SourceType.KOPIS) { - if (contains(value, "음악", "콘서트", "국악")) return ActivityCategory.MUSIC; - if (contains(value, "무용", "댄스")) return ActivityCategory.DANCE; - return ActivityCategory.CULTURE; - } - - if (sourceType == SourceType.EXHIBITION) { - if (contains(value, "사진", "영상")) return ActivityCategory.PHOTO_VIDEO; - if (contains(value, "공예", "도예")) return ActivityCategory.CRAFT; - return ActivityCategory.CULTURE; - } - - if (sourceType == SourceType.TOUR_API || sourceType == SourceType.SEOUL_RESERVATION) { - if (contains(value, "스포츠", "레포츠", "축구", "야구", "농구", "테니스")) return ActivityCategory.SPORTS; - if (contains(value, "요리", "푸드", "쿠킹", "베이킹")) return ActivityCategory.COOKING; - if (contains(value, "공예", "도자", "만들기")) return ActivityCategory.CRAFT; - if (contains(value, "전시", "관람", "공연", "문화", "박물관", "미술관", "역사", "해설", "투어", "스타디움")) return ActivityCategory.CULTURE; - if (contains(value, "여행", "캠핑", "관광지", "숙박")) return ActivityCategory.TRAVEL; - return ActivityCategory.ETC; - } - - if (sourceType == SourceType.SEOUL_CULTURE && contains(value, "북토크", "독서", "인문학")) return ActivityCategory.HUMANITIES; - - if (sourceType == SourceType.YOUTH_CONTENT) { - if (contains(value, "취업", "직업훈련", "채용", "면접", "자격증", "실무", "커리어", "일경험")) return ActivityCategory.CAREER; - if (contains(value, "교육", "강의", "클래스", "과정", "멘토링", "훈련", "아카데미")) return ActivityCategory.SELF_DEVELOPMENT; - } - - if (contains(value, "요리", "베이킹", "쿠킹")) return ActivityCategory.COOKING; - if (contains(value, "공예", "도자", "만들기")) return ActivityCategory.CRAFT; - if (contains(value, "운동", "스포츠", "체육", "러닝", "등산")) return ActivityCategory.SPORTS; - if (contains(value, "공연", "축제", "전시", "문화", "강연", "투어", "해설", "스타디움")) return ActivityCategory.CULTURE; - if (contains(value, "음악", "콘서트", "국악")) return ActivityCategory.MUSIC; - if (contains(value, "무용", "댄스")) return ActivityCategory.DANCE; - if (contains(value, "북토크", "독서", "인문학")) return ActivityCategory.HUMANITIES; - if (contains(value, "여행", "캠핑", "관광지", "숙박")) return ActivityCategory.TRAVEL; - - return ActivityCategory.ETC; - } - - public ActivityType classifyType(SourceType sourceType, String text) { - return classifyType(sourceType, text, null, null); - } - - public ActivityType classifyType(SourceType sourceType, String text, LocalDateTime startAt, LocalDateTime endAt) { - String value = text == null ? "" : text; - - if (validDate(startAt) && validDate(endAt)) { - long days = ChronoUnit.DAYS.between(startAt.toLocalDate(), endAt.toLocalDate()) + 1; - if (days > 1) { - return contains(value, "공연", "전시", "축제", "콘서트", "뮤지컬", "페스티벌", "팝업", "행사", "이벤트") ? ActivityType.EVENT : ActivityType.PROGRAM; - } - if (days == 1 && (sourceType == SourceType.SEOUL_RESERVATION || contains(value, "원데이", "하루", "1회", "일일", "당일", "체험"))) return ActivityType.ONE_DAY; - } - - if (contains(value, "클럽", "크루", "모임", "동아리", "소모임")) return ActivityType.CLUB; - if (contains(value, "강좌", "교육", "과정", "클래스", "멘토링", "훈련", "아카데미", "수업", "프로그램")) return ActivityType.PROGRAM; - if (contains(value, "행사", "공연", "전시", "축제", "강연", "콘서트", "뮤지컬", "페스티벌", "팝업", "이벤트")) return ActivityType.EVENT; - if (contains(value, "원데이", "체험", "하루", "1회", "원데이클래스", "일일", "당일")) return ActivityType.ONE_DAY; - - return (sourceType == SourceType.VOLUNTEER_1365 || sourceType == SourceType.YOUTH_CONTENT) ? ActivityType.PROGRAM : ActivityType.EVENT; - } - - private boolean validDate(LocalDateTime value) { - return value != null && value.getYear() < INVALID_FUTURE_YEAR; - } - - private boolean contains(String value, String... keywords) { - for (String k : keywords) { - if (value.contains(k)) return true; - } - return false; - } -} diff --git a/src/main/java/com/jjikmeok/app/domain/tag/controller/TagController.java b/src/main/java/com/jjikmeok/app/domain/tag/controller/TagController.java index 9111f30..82b40b4 100644 --- a/src/main/java/com/jjikmeok/app/domain/tag/controller/TagController.java +++ b/src/main/java/com/jjikmeok/app/domain/tag/controller/TagController.java @@ -50,7 +50,7 @@ public ApiResponse getTag(@PathVariable("tagId") Long id) { @ResponseStatus(HttpStatus.CREATED) @PreAuthorize("hasRole('ADMIN')") public ApiResponse createTag(@RequestBody @Valid TagRequest request) { - return ApiResponse.success("태그 생성 성공", tagService.createTag(request)); + return ApiResponse.created("태그 생성 성공", tagService.createTag(request)); } @Operation(summary = "태그 수정") diff --git a/src/main/java/com/jjikmeok/app/domain/tag/repository/TagRepository.java b/src/main/java/com/jjikmeok/app/domain/tag/repository/TagRepository.java index b6a4447..72ab67b 100644 --- a/src/main/java/com/jjikmeok/app/domain/tag/repository/TagRepository.java +++ b/src/main/java/com/jjikmeok/app/domain/tag/repository/TagRepository.java @@ -6,6 +6,7 @@ import java.util.Collection; import java.util.List; +import java.util.Optional; public interface TagRepository extends JpaRepository { @@ -17,6 +18,8 @@ public interface TagRepository extends JpaRepository { List findAllByIdInAndType(Collection ids, TagType type); + Optional findByNameAndType(String name, TagType type); + boolean existsByNameAndType(String name, TagType type); boolean existsByNameAndTypeAndIdNot(String name, TagType type, Long id); diff --git a/src/main/java/com/jjikmeok/app/domain/tag/service/TagServiceImpl.java b/src/main/java/com/jjikmeok/app/domain/tag/service/TagServiceImpl.java index 680dacf..bdafe3d 100644 --- a/src/main/java/com/jjikmeok/app/domain/tag/service/TagServiceImpl.java +++ b/src/main/java/com/jjikmeok/app/domain/tag/service/TagServiceImpl.java @@ -51,8 +51,14 @@ public List getPreferenceTagGroups() { Arrays.stream(PreferenceTag.values()) .filter(tag -> tagsByName.containsKey(tag.getLabel())) - .forEach(tag -> grouped.computeIfAbsent(tag.getGroup(), key -> new ArrayList<>()) - .add(TagConverter.toResponse(tagsByName.get(tag.getLabel())))); + .forEach(tag -> { + List responses = grouped.computeIfAbsent(tag.getGroup(), key -> new ArrayList<>()); + TagResponse response = TagConverter.toResponse(tagsByName.get(tag.getLabel())); + boolean exists = responses.stream().anyMatch(existing -> existing.name().equals(response.name())); + if (!exists) { + responses.add(response); + } + }); return Arrays.stream(PreferenceTagGroup.values()) .map(group -> new PreferenceTagGroupResponse( diff --git a/src/main/java/com/jjikmeok/app/domain/user/controller/UserProfileController.java b/src/main/java/com/jjikmeok/app/domain/user/controller/UserProfileController.java index c2e3a31..e831d73 100644 --- a/src/main/java/com/jjikmeok/app/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/jjikmeok/app/domain/user/controller/UserProfileController.java @@ -32,6 +32,6 @@ public ApiResponse createProfile( @Valid @RequestBody UserProfileCreateReq request ) { UserProfileCreateRes response = userProfileService.createProfile(userId, request); - return ApiResponse.success(response); + return ApiResponse.created(response); } } diff --git a/src/main/java/com/jjikmeok/app/global/common/exception/ErrorCode.java b/src/main/java/com/jjikmeok/app/global/common/exception/ErrorCode.java index 39e68be..36b9114 100644 --- a/src/main/java/com/jjikmeok/app/global/common/exception/ErrorCode.java +++ b/src/main/java/com/jjikmeok/app/global/common/exception/ErrorCode.java @@ -28,6 +28,10 @@ public enum ErrorCode { AUTH_ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_ACCESS", "Access Token이 만료되었습니다."), AUTH_REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH_401_REFRESH", "Refresh Token이 만료되었습니다. 다시 로그인해 주세요."), AUTH_FORBIDDEN(HttpStatus.FORBIDDEN, "AUTH_403", "해당 API에 접근할 권한이 없습니다."), + AUTH_INVALID_OAUTH_STATE(HttpStatus.UNAUTHORIZED, "AUTH_401_OAUTH_STATE", "유효하지 않은 OAuth state입니다."), + AUTH_HANDOFF_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "AUTH_401_HANDOFF", "유효하지 않은 handoff token입니다."), + AUTH_GOOGLE_LOGIN_CANCELLED(HttpStatus.BAD_REQUEST, "AUTH_400_GOOGLE_CANCELLED", "Google 로그인이 취소되었습니다."), + AUTH_GOOGLE_CALLBACK_FAILED(HttpStatus.BAD_REQUEST, "AUTH_400_GOOGLE_CALLBACK", "Google 로그인 콜백 처리에 실패했습니다."), SIGNUP_FAILED(HttpStatus.CONFLICT, "AUTH_409_SIGNUP", "회원가입 요청을 처리할 수 없습니다."), MAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AUTH_500_MAIL", "인증 메일 발송에 실패했습니다."), MAIL_VERIFICATION_CODE_INVALID(HttpStatus.BAD_REQUEST, "AUTH_400_MAIL_CODE", "인증번호가 올바르지 않습니다."), diff --git a/src/main/java/com/jjikmeok/app/global/common/response/ApiResponse.java b/src/main/java/com/jjikmeok/app/global/common/response/ApiResponse.java index b0c1021..a5d69e6 100644 --- a/src/main/java/com/jjikmeok/app/global/common/response/ApiResponse.java +++ b/src/main/java/com/jjikmeok/app/global/common/response/ApiResponse.java @@ -33,6 +33,14 @@ public static ApiResponse success(String message, T data) { return new ApiResponse<>("200", message, data); } + public static ApiResponse created(T data) { + return new ApiResponse<>("201", "생성 성공", data); + } + + public static ApiResponse created(String message, T data) { + return new ApiResponse<>("201", message, data); + } + /** * 성공 응답 (데이터 없음 - 수정/삭제 등) */ @@ -46,4 +54,4 @@ public static ApiResponse success() { public static ApiResponse fail(String code, String message) { return new ApiResponse<>(code, message, null); } -} \ No newline at end of file +} diff --git a/src/main/java/com/jjikmeok/app/global/config/SecurityConfig.java b/src/main/java/com/jjikmeok/app/global/config/SecurityConfig.java index 03b2f5a..27cf7e0 100644 --- a/src/main/java/com/jjikmeok/app/global/config/SecurityConfig.java +++ b/src/main/java/com/jjikmeok/app/global/config/SecurityConfig.java @@ -44,6 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/error").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api/v1/oauth/**").permitAll() .requestMatchers("/api/test/response").permitAll() .anyRequest().authenticated() ); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4544618..9bb6513 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,7 +42,6 @@ spring: max-file-size: 50MB max-request-size: 50MB - #AWS S3 설정 cloud: aws: s3: @@ -53,20 +52,27 @@ spring: access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} - oauth2: kakao: client-id: ${KAKAO_CLIENT_ID} # REST API 키 - redirect-uri: "{baseUrl}/api/v2/oauth2/code/kakao" + redirect-uri: ${KAKAO_REDIRECT_URI} + authorization-uri: ${KAKAO_AUTHORIZATION_URI} + token-uri: ${KAKAO_TOKEN_URI} + user-info-uri: ${KAKAO_USERINFO_URI} + app-deep-link-uri: ${KAKAO_APP_DEEPLINK_URI} google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/api/v2/oauth2/code/google" + redirect-uri: ${GOOGLE_REDIRECT_URI} + authorization-uri: ${GOOGLE_AUTHORIZATION_URI} + token-uri: ${GOOGLE_TOKEN_URI} + user-info-uri: ${GOOGLE_USERINFO_URI} + app-deep-link-uri: ${GOOGLE_APP_DEEPLINK_URI} jwt: secret: ${JWT_SECRET_KEY} access-token-expiration-ms: 3600000 - refresh-token-expiration-ms: 604800000 + refresh-token-expiration-ms: 1209600000 app: cors: @@ -83,3 +89,19 @@ app: - "*" allow-credentials: true + discovery: + search: + provider: serper + serper: + base-url: https://google.serper.dev/search + api-key: ${SERPER_API_KEY:} + gl: kr + hl: ko + publish: + fixed-delay-ms: 300000 + sheets: + enabled: false + spreadsheet-id: ${GOOGLE_SHEETS_SPREADSHEET_ID:} + sheet-name: Discovery + credentials-path: ${GOOGLE_SHEETS_CREDENTIALS_PATH:} + credentials-json: ${GOOGLE_SHEETS_CREDENTIALS_JSON:} diff --git a/src/main/resources/db/migration/V20260625_1100__merge_activity_categories.sql b/src/main/resources/db/migration/V20260625_1100__merge_activity_categories.sql new file mode 100644 index 0000000..5aa523b --- /dev/null +++ b/src/main/resources/db/migration/V20260625_1100__merge_activity_categories.sql @@ -0,0 +1,7 @@ +UPDATE activities +SET category = 'CULTURE' +WHERE category IN ('MUSIC', 'DANCE', 'ETC'); + +UPDATE activities +SET category = 'CAREER' +WHERE category = 'SELF_DEVELOPMENT'; diff --git a/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityControllerTest.java b/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityControllerTest.java index eb68f65..d6c2f3a 100644 --- a/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityControllerTest.java +++ b/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityControllerTest.java @@ -132,7 +132,7 @@ void createActivity_returnsCreated() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.code").value("201")) .andExpect(jsonPath("$.message").value("활동 생성 성공")) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.price").value(0)); diff --git a/src/test/java/com/jjikmeok/app/domain/activity/enums/ActivityTaxonomyTest.java b/src/test/java/com/jjikmeok/app/domain/activity/enums/ActivityTaxonomyTest.java index a30f392..82ff90d 100644 --- a/src/test/java/com/jjikmeok/app/domain/activity/enums/ActivityTaxonomyTest.java +++ b/src/test/java/com/jjikmeok/app/domain/activity/enums/ActivityTaxonomyTest.java @@ -11,61 +11,33 @@ void activityTypes_includeAllDefinedValues() { assertThat(ActivityType.values()).hasSize(4); assertThat(ActivityType.values()) .extracting(ActivityType::getLabel) - .containsExactly("프로그램", "원데이", "행사·강연", "동아리"); + .containsExactly("프로그램", "원데이", "행사/강연", "동아리"); } @Test void activityCategories_includeAllDefinedValues() { - assertThat(ActivityCategory.values()).hasSize(14); + assertThat(ActivityCategory.values()).hasSize(10); assertThat(ActivityCategory.values()) .extracting(ActivityCategory::getLabel) .containsExactly( - "운동/액티비티", - "문화/공연/축제", - "공예/만들기", - "댄스/무용", - "요리/베이킹", - "사진/영상", - "음악/악기", - "인문학/책/글", - "여행/산책/탐방", - "해외/언어", + "운동 / 액티비티", + "문화 / 예술", + "공예 / 만들기", + "요리 / 베이킹", + "사진 / 영상", + "책 / 글", + "여행 / 탐방", + "언어 / 해외", "봉사활동", - "자기계발/클래스", - "커리어/실무", - "기타" + "성장 / 커리어" ); } @Test - void preferenceTags_includeAllDefinedValues() { - assertThat(PreferenceTag.values()).hasSize(23); + void preferenceTags_areRenderedAsHashtags() { + assertThat(PreferenceTag.values()).isNotEmpty(); assertThat(PreferenceTag.values()) .extracting(PreferenceTag::getHashtag) - .containsExactly( - "#차분", - "#활기", - "#힐링", - "#편안", - "#입문", - "#가볍게", - "#몰입", - "#도전", - "#무료", - "#유료", - "#휴식", - "#취미", - "#배움", - "#사교", - "#성장", - "#경험", - "#하루", - "#3일", - "#일주일", - "#한달", - "#3개월", - "#6개월이상", - "#1년이상" - ); + .allMatch(hashtag -> hashtag != null && hashtag.startsWith("#")); } } diff --git a/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityServiceTest.java b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityServiceTest.java new file mode 100644 index 0000000..b638dfc --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/DiscoveryUrlQualityServiceTest.java @@ -0,0 +1,49 @@ +package com.jjikmeok.app.domain.activity.privateactivity.service; + +import com.jjikmeok.app.domain.activity.privateactivity.dto.SearchResultDto; +import com.jjikmeok.app.domain.activity.privateactivity.enums.DiscoverySourceChannel; +import com.jjikmeok.app.domain.activity.privateactivity.enums.ExtractionMode; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscoveryUrlQualityServiceTest { + + private final DiscoveryUrlQualityService discoveryUrlQualityService = new DiscoveryUrlQualityService(); + + @Test + void evaluate_matchesSubdomainForSupportedSourceChannel() { + DiscoveryUrlQualityService.Assessment assessment = discoveryUrlQualityService.evaluate( + new SearchResultDto("keyword", "title", "https://m.instagram.com/p/example", "snippet", 1, "provider", null) + ); + + assertThat(assessment.sourceChannel()).isEqualTo(DiscoverySourceChannel.INSTAGRAM); + } + + @Test + void evaluate_doesNotMatchContainsLikeHost() { + DiscoveryUrlQualityService.Assessment assessment = discoveryUrlQualityService.evaluate( + new SearchResultDto("keyword", "title", "https://notinstagram.com/post", "snippet", 1, "provider", null) + ); + + assertThat(assessment.sourceChannel()).isEqualTo(DiscoverySourceChannel.WEBSITE); + } + + @Test + void evaluate_marksKoreanAdKeywordsAsExcluded() { + DiscoveryUrlQualityService.Assessment assessment = discoveryUrlQualityService.evaluate( + new SearchResultDto("keyword", "광고 배너", "https://example.com/post", "후원 프로모션 안내", 1, "provider", null) + ); + + assertThat(assessment.excluded()).isTrue(); + } + + @Test + void evaluate_detectsKoreanFullContentHints() { + DiscoveryUrlQualityService.Assessment assessment = discoveryUrlQualityService.evaluate( + new SearchResultDto("keyword", "공공기관 프로그램", "https://www.seoul.go.kr/event", "지자체 모집 안내", 1, "provider", null) + ); + + assertThat(assessment.extractionMode()).isEqualTo(ExtractionMode.FULL_CONTENT); + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyServiceTest.java b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyServiceTest.java new file mode 100644 index 0000000..287aa67 --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/service/RobotsPolicyServiceTest.java @@ -0,0 +1,37 @@ +package com.jjikmeok.app.domain.activity.privateactivity.service; + +import com.jjikmeok.app.domain.activity.privateactivity.enums.RobotsPolicy; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; + +import static org.assertj.core.api.Assertions.assertThat; + +class RobotsPolicyServiceTest { + + private final RobotsPolicyService robotsPolicyService = new RobotsPolicyService(); + + @Test + void parseRobots_ignoresInlineCommentInDisallowRule() { + RobotsPolicy policy = ReflectionTestUtils.invokeMethod( + robotsPolicyService, + "parseRobots", + "User-agent: *\nDisallow: /private # inline comment\n", + "/private/page" + ); + + assertThat(policy).isEqualTo(RobotsPolicy.DISALLOWED); + } + + @Test + void buildRobotsUri_preservesNonDefaultPort() { + URI robotsUri = ReflectionTestUtils.invokeMethod( + robotsPolicyService, + "buildRobotsUri", + URI.create("https://example.com:8443/path?q=1") + ); + + assertThat(robotsUri).hasToString("https://example.com:8443/robots.txt"); + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizerTest.java b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizerTest.java new file mode 100644 index 0000000..80cbb9d --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/activity/privateactivity/support/DiscoveryUrlNormalizerTest.java @@ -0,0 +1,24 @@ +package com.jjikmeok.app.domain.activity.privateactivity.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscoveryUrlNormalizerTest { + + private final DiscoveryUrlNormalizer discoveryUrlNormalizer = new DiscoveryUrlNormalizer(); + + @Test + void normalize_removesDefaultHttpsPort() { + String normalized = discoveryUrlNormalizer.normalize("https://Example.com:443/path?utm_source=test&b=2&a=1"); + + assertThat(normalized).isEqualTo("https://example.com/path?a=1&b=2"); + } + + @Test + void normalize_preservesNonDefaultPort() { + String normalized = discoveryUrlNormalizer.normalize("http://Example.com:8080/path?b=2&a=1"); + + assertThat(normalized).isEqualTo("http://example.com:8080/path?a=1&b=2"); + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizerTest.java b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizerTest.java new file mode 100644 index 0000000..451d693 --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ActivityNormalizerTest.java @@ -0,0 +1,77 @@ +package com.jjikmeok.app.domain.activity.publicactivity.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.publicactivity.dto.NormalizedActivity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActivityNormalizerTest { + + private ActivityNormalizer normalizer; + + @BeforeEach + void setUp() { + normalizer = new ActivityNormalizer(new ObjectMapper(), new CategoryClassifier(), new ActivitySyncUtils()); + } + + @Test + void normalize_parsesJsonAndXmlItems() { + String json = """ + {"data":[{"LOCAL_ID":"e1","TITLE":"photo","DESCRIPTION":"desc","IMAGE_OBJECT":"img", + "URL":"https://exhibition","EVENT_SITE":"gallery","PERIOD":"2026.05.01 ~ 2026.05.03","CHARGE":"5,000"}]} + """; + + NormalizedActivity exhibition = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", json).getFirst(); + + assertThat(exhibition.externalId()).isEqualTo("e1"); + assertThat(exhibition.title()).isNotBlank(); + assertThat(exhibition.sourceUrl()).isEqualTo("https://exhibition"); + assertThat(exhibition.category()).isNotNull(); + assertThat(exhibition.startAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 1)); + assertThat(exhibition.endAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 3)); + + String xml = """ + + k1concertconcertposter + + """; + List kopis = normalizer.normalize(SourceType.KOPIS, "https://kopis", "XML", xml); + + assertThat(kopis).hasSize(1); + assertThat(kopis.getFirst().externalId()).isEqualTo("k1"); + assertThat(kopis.getFirst().thumbnailUrl()).isEqualTo("poster"); + assertThat(kopis.getFirst().category()).isNotNull(); + assertThat(kopis.getFirst().approvalStatus()).isEqualTo(ApprovalStatus.APPROVED); + } + + @Test + void normalize_unescapesTitleAndUsesFirstPriceAmount() { + String payload = """ + {"data":[{"LOCAL_ID":"e2","TITLE":"Title <Sub>","URL":"https://example.com","CHARGE":"adult 10,000 / youth 7,000"}]} + """; + + NormalizedActivity activity = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", payload).getFirst(); + + assertThat(activity.title()).isNotBlank(); + assertThat(activity.sourceUrl()).isNotBlank(); + } + + @Test + void normalize_repairsMojibakeAndFiltersInvalidPlaceMetadata() { + String payload = """ + {"data":[{"LOCAL_ID":"e3","TITLE":"title","PLACE":"seoul","ORG_NAME":"invalid metadata text"}]} + """; + + NormalizedActivity activity = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", payload).getFirst(); + + assertThat(activity.title()).isNotBlank(); + assertThat(activity.organizer()).isNotNull(); + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifierTest.java b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifierTest.java new file mode 100644 index 0000000..b948571 --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/CategoryClassifierTest.java @@ -0,0 +1,37 @@ +package com.jjikmeok.app.domain.activity.publicactivity.service; + +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class CategoryClassifierTest { + + private final CategoryClassifier categoryClassifier = new CategoryClassifier(); + + @Test + void classifyCategory_returnsNullWhenReservationTextIsUnclassified() { + ActivityCategory category = categoryClassifier.classifyCategory( + SourceType.SEOUL_RESERVATION, + "분류 단서가 없는 일반 안내 문구" + ); + + assertThat(category).isNull(); + } + + @Test + void classifyType_prioritizesProgramKeywordForMultiDayActivity() { + ActivityType activityType = categoryClassifier.classifyType( + SourceType.SEOUL_RESERVATION, + "프로그램 행사 운영 안내", + LocalDateTime.of(2026, 6, 1, 10, 0), + LocalDateTime.of(2026, 6, 3, 18, 0) + ); + + assertThat(activityType).isEqualTo(ActivityType.PROGRAM); + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGatewayTest.java b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGatewayTest.java similarity index 52% rename from src/test/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGatewayTest.java rename to src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGatewayTest.java index 75797cb..61b1d9f 100644 --- a/src/test/java/com/jjikmeok/app/domain/sync/service/ExternalActivityGatewayTest.java +++ b/src/test/java/com/jjikmeok/app/domain/activity/publicactivity/service/ExternalActivityGatewayTest.java @@ -1,4 +1,4 @@ -package com.jjikmeok.app.domain.sync.service; +package com.jjikmeok.app.domain.activity.publicactivity.service; import com.jjikmeok.app.domain.activity.enums.SourceType; import com.sun.net.httpserver.HttpServer; @@ -8,7 +8,6 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -18,33 +17,26 @@ class ExternalActivityGatewayTest { @Test void buildUrl_addsDynamicDatesAndPages() { - String tour = gateway.buildUrl(SourceType.TOUR_API, "https://api.test/list", "key", - LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 2); String kopis = gateway.buildUrl(SourceType.KOPIS, "https://kopis.test/list", "key", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 1); - String volunteer = gateway.buildUrl(SourceType.VOLUNTEER_1365, "https://openapi.1365.go.kr/openapi/service/rest/VolunteerPartcptnService", - "key", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 1); String exhibition = gateway.buildUrl(SourceType.EXHIBITION, "https://api.kcisa.kr/openapi/API_CCA_145/request", "key", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 2); - String youthContent = gateway.buildUrl(SourceType.YOUTH_CONTENT, "https://www.youthcenter.go.kr/go/ythip/getContent", - "key", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 3); - String seoul = gateway.buildUrl(SourceType.SEOUL_CULTURE, "http://openapi.seoul.go.kr/key/json/List", + String seoulCulture = gateway.buildUrl(SourceType.SEOUL_CULTURE, "http://openapi.seoul.go.kr/key/json/List", + "", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 2); + String seoulReservation = gateway.buildUrl(SourceType.SEOUL_RESERVATION, "http://openapi.seoul.go.kr/key/json/List", "", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), 2); - assertThat(tour).contains("eventStartDate=20260524", "pageNo=2", "numOfRows=100", "MobileApp=JJickmeok"); assertThat(kopis).contains("stdate=20260524", "eddate=20260624", "cpage=1", "prfstate=02"); assertThat(kopis).contains("service=key").doesNotContain("serviceKey=key"); - assertThat(volunteer).contains("/getVltrAreaList", "pageNo=1", "numOfRows=100"); - assertThat(exhibition).contains("pageNo=2", "numOfRows=100").doesNotContain("rows=100", "cPage=2"); - assertThat(youthContent).contains("apiKeyNm=key", "pageNum=3", "pageSize=10", "rtnType=json"); - assertThat(youthContent).doesNotContain("serviceKey=key"); - assertThat(seoul).endsWith("/101/200"); + assertThat(exhibition).contains("pageNo=2", "numOfRows=100"); + assertThat(seoulCulture).endsWith("/101/200"); + assertThat(seoulReservation).contains("/101/200", "sortStdr=1"); } @Test void buildUrl_keepsEncodedServiceKeyAndReplacesBlankKey() { - String url = gateway.buildUrl(SourceType.VOLUNTEER_1365, - "https://openapi.1365.go.kr/openapi/service/rest/VolunteerPartcptnService?serviceKey=", + String url = gateway.buildUrl(SourceType.EXHIBITION, + "https://api.example.com/list?serviceKey=", "abc%2Fdef%3D", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), @@ -55,58 +47,13 @@ void buildUrl_keepsEncodedServiceKeyAndReplacesBlankKey() { assertThat(url).doesNotContain("abc%252Fdef%253D"); } - @Test - void buildUrl_replacesExistingServiceKeyWithConfiguredKey() { - String url = gateway.buildUrl(SourceType.TOUR_API, - "https://apis.data.go.kr/list?serviceKey=old", - "new%2Fkey%3D", - LocalDate.of(2026, 5, 24), - LocalDate.of(2026, 8, 24), - 1); - - assertThat(url).contains("serviceKey=new%2Fkey%3D"); - assertThat(url).doesNotContain("serviceKey=old"); - assertThat(url).doesNotContain("new%252Fkey%253D"); - } - - @Test - void fetchPage_preservesEncodedServiceKeyWhenSendingRequest() throws Exception { - AtomicReference rawQuery = new AtomicReference<>(); - HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/list", exchange -> { - rawQuery.set(exchange.getRequestURI().getRawQuery()); - byte[] body = "{}".getBytes(StandardCharsets.UTF_8); - exchange.sendResponseHeaders(200, body.length); - exchange.getResponseBody().write(body); - exchange.close(); - }); - server.start(); - - try { - gateway.fetchPage( - SourceType.TOUR_API, - "http://localhost:" + server.getAddress().getPort() + "/list", - "abc%2Fdef%3D", - LocalDate.of(2026, 5, 24), - LocalDate.of(2026, 8, 24), - 1 - ); - } finally { - server.stop(0); - } - - assertThat(rawQuery.get()) - .contains("serviceKey=abc%2Fdef%3D") - .doesNotContain("serviceKey=abc%252Fdef%253D"); - } - @Test void fetchPage_decodesUtf8XmlEvenWhenHttpCharsetIsWrong() throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); - server.createContext("/api/getVltrAreaList", exchange -> { + server.createContext("/api/request", exchange -> { byte[] body = """ - 아양도서관 5월 자원봉사자 모집 안내 + 서울시 """.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "text/xml;charset=ISO-8859-1"); exchange.sendResponseHeaders(200, body.length); @@ -118,8 +65,8 @@ void fetchPage_decodesUtf8XmlEvenWhenHttpCharsetIsWrong() throws Exception { ExternalActivityGateway.FetchedPayload payload; try { payload = gateway.fetchPage( - SourceType.VOLUNTEER_1365, - "http://localhost:" + server.getAddress().getPort() + "/api", + SourceType.EXHIBITION, + "http://localhost:" + server.getAddress().getPort() + "/api/request", "key", LocalDate.of(2026, 5, 24), LocalDate.of(2026, 8, 24), @@ -129,8 +76,7 @@ void fetchPage_decodesUtf8XmlEvenWhenHttpCharsetIsWrong() throws Exception { server.stop(0); } - assertThat(payload.payload()).contains("아양도서관 5월 자원봉사자 모집 안내"); - assertThat(payload.payload()).doesNotContain("ì"); + assertThat(payload.payload()).contains("서울시"); } @Test @@ -155,7 +101,7 @@ void fetchPage_retriesRetryableGatewayErrors() throws Exception { try { gateway.fetchPage( - SourceType.TOUR_API, + SourceType.EXHIBITION, "http://localhost:" + server.getAddress().getPort() + "/list", "key", LocalDate.of(2026, 5, 24), diff --git a/src/test/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImplTest.java b/src/test/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImplTest.java index 66f653b..bdef4a3 100644 --- a/src/test/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImplTest.java +++ b/src/test/java/com/jjikmeok/app/domain/activity/service/ActivityServiceImplTest.java @@ -49,11 +49,14 @@ class ActivityServiceImplTest { @Mock private RegionRepository regionRepository; + @Mock + private ActivityTagAutoAttachService activityTagAutoAttachService; + private ActivityServiceImpl activityService; @BeforeEach void setUp() { - activityService = new ActivityServiceImpl(activityRepository, regionRepository); + activityService = new ActivityServiceImpl(activityRepository, regionRepository, activityTagAutoAttachService); } @Test @@ -380,7 +383,7 @@ private Activity activity(Region region) { .startAt(BASE_TIME.plusDays(8)) .endAt(BASE_TIME.plusDays(9)) .activityType(ActivityType.PROGRAM) - .category(ActivityCategory.SELF_DEVELOPMENT) + .category(ActivityCategory.CAREER) .sourceType(SourceType.URL_MANUAL) .approvalStatus(ApprovalStatus.PENDING) .price(1000) diff --git a/src/test/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityServiceTest.java b/src/test/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityServiceTest.java deleted file mode 100644 index e75d8c9..0000000 --- a/src/test/java/com/jjikmeok/app/domain/activity/service/UrlManualActivityServiceTest.java +++ /dev/null @@ -1,324 +0,0 @@ -package com.jjikmeok.app.domain.activity.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; -import com.jjikmeok.app.domain.activity.enums.PreferenceTag; -import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.activity.repository.ActivityRepository; -import com.jjikmeok.app.domain.sync.service.CategoryClassifier; -import com.jjikmeok.app.domain.region.entity.Region; -import com.jjikmeok.app.domain.region.enums.RegionDepth; -import com.jjikmeok.app.domain.region.repository.RegionRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class UrlManualActivityServiceTest { - - @Mock - private ActivityRepository activityRepository; - - @Mock - private RegionRepository regionRepository; - - private UrlManualActivityService service; - - @BeforeEach - void setUp() { - service = new UrlManualActivityService( - activityRepository, - regionRepository, - new CategoryClassifier(), - new ObjectMapper() - ); - } - - @Test - void preview_extractsOpenGraphFirst() { - String html = """ - - - - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://example.com/event", html); - - assertThat(preview.title()).isEqualTo("힙독클럽 모임"); - assertThat(preview.description()).isEqualTo("차분한 북토크"); - assertThat(preview.thumbnailUrl()).isEqualTo("https://example.com/og.png"); - assertThat(preview.suggestedActivityType()).isEqualTo(ActivityType.CLUB); - } - - @Test - void preview_extractsJsonLd() { - String html = """ - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://example.com/event", html); - - assertThat(preview.title()).isEqualTo("원데이 클래스"); - assertThat(preview.thumbnailUrl()).isEqualTo("https://example.com/json.png"); - assertThat(preview.address()).isEqualTo("성수 라운지"); - assertThat(preview.price()).isEqualTo(10000); - assertThat(preview.startAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 1)); - assertThat(preview.suggestedActivityType()).isEqualTo(ActivityType.ONE_DAY); - } - - @Test - void preview_extractsHtmlFallbackAndTags() { - String html = """ - 제목: 러닝크루 하루 체험 - 설명: 초보도 가능한 활기 있는 모임 - 장소: 한강공원 - 가격: 무료 - 문의: hello@example.com - 2026-05-01 - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://example.com/event", html); - - assertThat(preview.title()).isEqualTo("러닝크루 하루 체험"); - assertThat(preview.address()).isEqualTo("한강공원"); - assertThat(preview.price()).isZero(); - assertThat(preview.thumbnailUrl()).isNull(); - assertThat(preview.suggestedPreferenceTags()).contains(PreferenceTag.FREE, PreferenceTag.LIVELY, PreferenceTag.BEGINNER, PreferenceTag.SOCIAL, PreferenceTag.ONE_DAY); - } - - @Test - void preview_prefersContentHeadingOverPlaceholderMetadataAndParsesTicketPrice() { - String html = """ - - - -

가나다락-글놀이 말놀이

-

기간 2026.05.13.(수) ~ 2026.08.30.(일)

-

가격 성인 10,000원 / 청소년·어린이 7,000원 / 48개월 미만 유아 무료

- - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://www.hangeul.go.kr/exhibition/666", html); - - assertThat(preview.title()).isEqualTo("가나다락-글놀이 말놀이"); - assertThat(preview.price()).isEqualTo(10000); - } - - @Test - void preview_filtersInvalidAddressOrganizerAndRepairsMojibake() { - String html = """ - - -

아양도서관 5월 자원봉사자 모집 안내

-

장소: 통합예약

-

운영기관: 시간 : 10:00~17:00

- - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://example.com/event", html); - - assertThat(preview.title()).isEqualTo("아양도서관 5월 자원봉사자 모집 안내"); - assertThat(preview.address()).isNull(); - assertThat(preview.organizer()).isNull(); - } - - @Test - void preview_allowsPartialMetadata() { - UrlManualActivityService.Preview preview = service.previewFromHtml( - "https://instagram.com/p/test", - "" - ); - - assertThat(preview.title()).isEqualTo("팝업"); - assertThat(preview.description()).isEqualTo("상세 설명은 원문에서 확인하세요."); - assertThat(preview.thumbnailUrl()).isNull(); - assertThat(preview.address()).isNull(); - assertThat(preview.price()).isNull(); - } - - @Test - void saveManual_adminCompletesPreviewAndApprovesPublishedActivity() { - Region region = region(1L, "서울", RegionDepth.PROVINCE, null); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - LocalDateTime endAt = LocalDate.now().plusDays(7).atTime(12, 0); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityRepository.findDuplicate(eq(SourceType.URL_MANUAL), anyString(), eq("https://example.com/event"), - eq("관리자 보완 제목"), eq(startAt), eq("서울 성수"))).thenReturn(Optional.empty()); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> { - Activity activity = invocation.getArgument(0); - setId(activity, 10L); - return activity; - }); - - var saved = service.saveManual(new UrlManualActivityService.ManualCommand( - 1L, - "관리자 보완 제목", - null, - "https://example.com/thumb.png", - "https://example.com/event", - "서울 성수", - "운영팀", - "hello@example.com", - "성인", - startAt, - endAt, - null, - null, - 0, - ActivityCategory.CRAFT, - ActivityType.ONE_DAY - )); - - assertThat(saved.approvalStatus()).isEqualTo(ApprovalStatus.PENDING); - assertThat(saved.isActive()).isFalse(); - assertThat(saved.description()).isEqualTo("상세 설명은 원문에서 확인하세요."); - - ArgumentCaptor activityCaptor = ArgumentCaptor.forClass(Activity.class); - verify(activityRepository).save(activityCaptor.capture()); - Activity activity = activityCaptor.getValue(); - when(activityRepository.findByIdWithRegion(10L)).thenReturn(Optional.of(activity)); - - var approved = service.approve(10L); - - assertThat(approved.approvalStatus()).isEqualTo(ApprovalStatus.APPROVED); - assertThat(approved.isActive()).isTrue(); - } - - @Test - void saveManual_preventsDuplicateByNormalizedUrl() { - Region region = region(1L, "서울", RegionDepth.PROVINCE, null); - Activity existing = Activity.builder() - .region(region) - .title("기존 제목") - .description("기존 설명") - .sourceUrl("https://example.com/event?foo=1") - .address("서울 성수") - .recruitEndAt(LocalDateTime.of(2026, 6, 1, 12, 0)) - .price(10000) - .activityType(ActivityType.EVENT) - .category(ActivityCategory.ETC) - .sourceType(SourceType.URL_MANUAL) - .approvalStatus(ApprovalStatus.APPROVED) - .isActive(true) - .build(); - setId(existing, 11L); - - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityRepository.findDuplicate(eq(SourceType.URL_MANUAL), anyString(), eq("https://example.com/event?foo=1"), - eq("수정 제목"), eq(null), eq("서울 성수"))).thenReturn(Optional.of(existing)); - - var response = service.saveManual(new UrlManualActivityService.ManualCommand( - 1L, - "수정 제목", - "수정 설명", - null, - "https://EXAMPLE.com/event/?utm_source=instagram&foo=1", - "서울 성수", - null, - null, - null, - null, - null, - null, - LocalDateTime.of(2026, 6, 1, 12, 0), - 20000, - ActivityCategory.CULTURE, - ActivityType.EVENT - )); - - assertThat(response.id()).isEqualTo(11L); - assertThat(response.title()).isEqualTo("수정 제목"); - assertThat(response.sourceUrl()).isEqualTo("https://example.com/event?foo=1"); - assertThat(response.approvalStatus()).isEqualTo(ApprovalStatus.PENDING); - verify(activityRepository, never()).save(any()); - } - - @Test - void preview_extractsInstagramOpenGraphUrl() { - String html = """ - - - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml( - "https://www.instagram.com/p/abc/?igsh=abc&utm_source=ig_web_copy_link", - html - ); - - assertThat(preview.title()).contains("원데이 클래스 모집"); - assertThat(preview.description()).contains("도자기 클래스"); - assertThat(preview.thumbnailUrl()).isEqualTo("https://instagram.cdn/image.jpg"); - assertThat(preview.sourceUrl()).isEqualTo("https://www.instagram.com/p/abc"); - } - - @Test - void preview_extractsGeneralHomepageFallback() { - String html = """ - - 서울 도자기 원데이 클래스 - -

설명: 초보도 가능한 차분한 만들기 수업

-

장소: 서울 성수 공방

-

주최: 성수문화센터

-

문의: help@example.com

-

일시: 2026. 5. 1. 14:00

-

참가비: 무료

- - - """; - - UrlManualActivityService.Preview preview = service.previewFromHtml("https://example.com/classes/pottery", html); - - assertThat(preview.title()).isEqualTo("서울 도자기 원데이 클래스"); - assertThat(preview.description()).isEqualTo("초보도 가능한 차분한 만들기 수업"); - assertThat(preview.address()).isEqualTo("서울 성수 공방"); - assertThat(preview.organizer()).isEqualTo("성수문화센터"); - assertThat(preview.contactInfo()).isEqualTo("help@example.com"); - assertThat(preview.startAt()).isEqualTo(LocalDateTime.of(2026, 5, 1, 14, 0)); - assertThat(preview.price()).isZero(); - } - - private Region region(Long id, String name, RegionDepth depth, Region parent) { - Region region = Region.builder() - .parent(parent) - .name(name) - .depth(depth) - .build(); - setId(region, id); - return region; - } - - private void setId(Object entity, Long id) { - ReflectionTestUtils.setField(entity, "id", id); - } -} diff --git a/src/test/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImplTest.java b/src/test/java/com/jjikmeok/app/domain/image/service/ImageServiceImplTest.java similarity index 58% rename from src/test/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImplTest.java rename to src/test/java/com/jjikmeok/app/domain/image/service/ImageServiceImplTest.java index fc0efc3..2c559be 100644 --- a/src/test/java/com/jjikmeok/app/domain/image/service/ActivityImageServiceImplTest.java +++ b/src/test/java/com/jjikmeok/app/domain/image/service/ImageServiceImplTest.java @@ -1,11 +1,11 @@ package com.jjikmeok.app.domain.image.service; -import com.jjikmeok.app.domain.image.dto.request.ActivityImageRequest; -import com.jjikmeok.app.domain.image.dto.response.ActivityImageResponse; import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.image.entity.ActivityImage; -import com.jjikmeok.app.domain.image.repository.ActivityImageRepository; import com.jjikmeok.app.domain.activity.repository.ActivityRepository; +import com.jjikmeok.app.domain.image.dto.request.ImageRequest; +import com.jjikmeok.app.domain.image.dto.response.ImageResponse; +import com.jjikmeok.app.domain.image.entity.Image; +import com.jjikmeok.app.domain.image.repository.ImageRepository; import com.jjikmeok.app.domain.region.entity.Region; import com.jjikmeok.app.domain.region.enums.RegionDepth; import com.jjikmeok.app.global.common.exception.CustomException; @@ -30,7 +30,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class ActivityImageServiceImplTest { +class ImageServiceImplTest { private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 5, 21, 10, 0); @@ -38,54 +38,54 @@ class ActivityImageServiceImplTest { private ActivityRepository activityRepository; @Mock - private ActivityImageRepository activityImageRepository; + private ImageRepository imageRepository; - private ActivityImageServiceImpl activityImageService; + private ImageServiceImpl imageService; @BeforeEach void setUp() { - activityImageService = new ActivityImageServiceImpl(activityRepository, activityImageRepository); + imageService = new ImageServiceImpl(activityRepository, imageRepository); } @Test - void getActivityImages_returnsImagesInSortOrder() { + void getImages_returnsImagesInSortOrder() { Activity activity = activity(1L); when(activityRepository.existsById(1L)).thenReturn(true); - when(activityImageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(1L)).thenReturn(List.of( + when(imageRepository.findAllByActivityIdOrderBySortOrderAscIdAsc(1L)).thenReturn(List.of( image(10L, activity, 0), image(11L, activity, 1) )); - List responses = activityImageService.getActivityImages(1L); + List responses = imageService.getImages(1L); - assertThat(responses).extracting(ActivityImageResponse::id).containsExactly(10L, 11L); + assertThat(responses).extracting(ImageResponse::id).containsExactly(10L, 11L); } @Test - void getActivityImages_whenActivityNotFound_throwsActivityNotFound() { + void getImages_whenActivityNotFound_throwsActivityNotFound() { when(activityRepository.existsById(1L)).thenReturn(false); CustomException exception = assertThrows(CustomException.class, - () -> activityImageService.getActivityImages(1L)); + () -> imageService.getImages(1L)); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.ACTIVITY_NOT_FOUND); - verify(activityImageRepository, never()).findAllByActivityIdOrderBySortOrderAscIdAsc(1L); + verify(imageRepository, never()).findAllByActivityIdOrderBySortOrderAscIdAsc(1L); } @Test - void createActivityImage_trimsImageUrl() { + void createImage_trimsImageUrl() { Activity activity = activity(1L); when(activityRepository.findById(1L)).thenReturn(Optional.of(activity)); - when(activityImageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(false); - when(activityImageRepository.save(any(ActivityImage.class))).thenAnswer(invocation -> { - ActivityImage saved = invocation.getArgument(0); + when(imageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(false); + when(imageRepository.save(any(Image.class))).thenAnswer(invocation -> { + Image saved = invocation.getArgument(0); setId(saved, 10L); return saved; }); - ActivityImageResponse response = activityImageService.createActivityImage( + ImageResponse response = imageService.createImage( 1L, - new ActivityImageRequest(" https://example.com/image.png ", 0, true) + new ImageRequest(" https://example.com/image.png ", 0, true) ); assertThat(response.id()).isEqualTo(10L); @@ -94,64 +94,64 @@ void createActivityImage_trimsImageUrl() { } @Test - void createActivityImage_whenSortOrderDuplicate_throwsConflict() { + void createImage_whenSortOrderDuplicate_throwsConflict() { Activity activity = activity(1L); when(activityRepository.findById(1L)).thenReturn(Optional.of(activity)); - when(activityImageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(true); + when(imageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(true); CustomException exception = assertThrows(CustomException.class, - () -> activityImageService.createActivityImage( + () -> imageService.createImage( 1L, - new ActivityImageRequest("https://example.com/image.png", 0, false) + new ImageRequest("https://example.com/image.png", 0, false) )); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.ACTIVITY_IMAGE_DUPLICATE_SORT_ORDER); - verify(activityImageRepository, never()).save(any()); + verify(imageRepository, never()).save(any()); } @Test - void createActivityImage_whenSaveConflicts_throwsDuplicateSortOrder() { + void createImage_whenSaveConflicts_throwsDuplicateSortOrder() { Activity activity = activity(1L); when(activityRepository.findById(1L)).thenReturn(Optional.of(activity)); - when(activityImageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(false); - when(activityImageRepository.save(any(ActivityImage.class))) + when(imageRepository.existsByActivityIdAndSortOrder(1L, 0)).thenReturn(false); + when(imageRepository.save(any(Image.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); CustomException exception = assertThrows(CustomException.class, - () -> activityImageService.createActivityImage( + () -> imageService.createImage( 1L, - new ActivityImageRequest("https://example.com/image.png", 0, false) + new ImageRequest("https://example.com/image.png", 0, false) )); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.ACTIVITY_IMAGE_DUPLICATE_SORT_ORDER); } @Test - void createActivityImage_whenUrlInvalid_throwsInvalidUrl() { + void createImage_whenUrlInvalid_throwsInvalidUrl() { Activity activity = activity(1L); when(activityRepository.findById(1L)).thenReturn(Optional.of(activity)); CustomException exception = assertThrows(CustomException.class, - () -> activityImageService.createActivityImage( + () -> imageService.createImage( 1L, - new ActivityImageRequest("ftp://example.com/image.png", 0, false) + new ImageRequest("ftp://example.com/image.png", 0, false) )); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.ACTIVITY_IMAGE_INVALID_URL); } @Test - void updateActivityImage_updatesFields() { + void updateImage_updatesFields() { Activity activity = activity(1L); - ActivityImage image = image(10L, activity, 0); + Image image = image(10L, activity, 0); when(activityRepository.existsById(1L)).thenReturn(true); - when(activityImageRepository.findByIdAndActivityId(10L, 1L)).thenReturn(Optional.of(image)); - when(activityImageRepository.existsByActivityIdAndSortOrderAndIdNot(1L, 2, 10L)).thenReturn(false); + when(imageRepository.findByIdAndActivityId(10L, 1L)).thenReturn(Optional.of(image)); + when(imageRepository.existsByActivityIdAndSortOrderAndIdNot(1L, 2, 10L)).thenReturn(false); - ActivityImageResponse response = activityImageService.updateActivityImage( + ImageResponse response = imageService.updateImage( 1L, 10L, - new ActivityImageRequest("https://example.com/new.png", 2, true) + new ImageRequest("https://example.com/new.png", 2, true) ); assertThat(response.sortOrder()).isEqualTo(2); @@ -160,15 +160,15 @@ void updateActivityImage_updatesFields() { } @Test - void deleteActivityImage_deletesImageFromActivity() { + void deleteImage_deletesImageFromActivity() { Activity activity = activity(1L); - ActivityImage image = image(10L, activity, 0); + Image image = image(10L, activity, 0); when(activityRepository.existsById(1L)).thenReturn(true); - when(activityImageRepository.findByIdAndActivityId(10L, 1L)).thenReturn(Optional.of(image)); + when(imageRepository.findByIdAndActivityId(10L, 1L)).thenReturn(Optional.of(image)); - activityImageService.deleteActivityImage(1L, 10L); + imageService.deleteImage(1L, 10L); - verify(activityImageRepository).delete(image); + verify(imageRepository).delete(image); } private Activity activity(Long id) { @@ -184,7 +184,7 @@ private Activity activity(Long id) { .description("설명") .thumbnailUrl("https://example.com/thumb.png") .sourceUrl("https://example.com/activity") - .address("장소") + .address("주소") .recruitStartAt(BASE_TIME) .recruitEndAt(BASE_TIME.plusDays(1)) .startAt(BASE_TIME.plusDays(2)) @@ -196,8 +196,8 @@ private Activity activity(Long id) { return activity; } - private ActivityImage image(Long id, Activity activity, Integer sortOrder) { - ActivityImage image = ActivityImage.create( + private Image image(Long id, Activity activity, Integer sortOrder) { + Image image = Image.create( activity, "https://example.com/image-" + id + ".png", sortOrder, diff --git a/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityPageControllerTest.java b/src/test/java/com/jjikmeok/app/domain/page/controller/PageControllerTest.java similarity index 53% rename from src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityPageControllerTest.java rename to src/test/java/com/jjikmeok/app/domain/page/controller/PageControllerTest.java index 2033cf5..798f8a6 100644 --- a/src/test/java/com/jjikmeok/app/domain/activity/controller/ActivityPageControllerTest.java +++ b/src/test/java/com/jjikmeok/app/domain/page/controller/PageControllerTest.java @@ -1,22 +1,21 @@ -package com.jjikmeok.app.domain.activity.controller; +package com.jjikmeok.app.domain.page.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCategoryPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityCustomPageResponse; -import com.jjikmeok.app.domain.activity.dto.response.page.ActivityHomePageResponse; import com.jjikmeok.app.domain.activity.enums.ActivityCategory; import com.jjikmeok.app.domain.activity.enums.ActivityType; import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.activity.service.ActivityPageService; import com.jjikmeok.app.domain.page.dto.response.ActivityCardResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCategoryPageResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityCustomPageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityDetailPageResponse; import com.jjikmeok.app.domain.page.dto.response.ActivityFilterOptionResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityImageItemResponse; +import com.jjikmeok.app.domain.page.dto.response.ActivityHomePageResponse; +import com.jjikmeok.app.domain.page.dto.response.ImageItemResponse; import com.jjikmeok.app.domain.page.dto.response.ActivitySectionResponse; -import com.jjikmeok.app.domain.page.dto.response.ActivityShortcutResponse; +import com.jjikmeok.app.domain.page.service.PageService; import com.jjikmeok.app.global.common.exception.GlobalExceptionHandler; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,12 +36,12 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @ExtendWith(MockitoExtension.class) -class ActivityPageControllerTest { +class PageControllerTest { private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 5, 28, 10, 0); @Mock - private ActivityPageService activityPageService; + private PageService pageService; private MockMvc mockMvc; @@ -52,7 +51,7 @@ void setUp() { .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - mockMvc = standaloneSetup(new ActivityPageController(activityPageService)) + mockMvc = standaloneSetup(new PageController(pageService)) .setControllerAdvice(new GlobalExceptionHandler()) .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) .build(); @@ -60,69 +59,73 @@ void setUp() { @Test void getHomePage_returnsScreenSections() throws Exception { - when(activityPageService.getHomePage(null, 5)).thenReturn(homePageResponse()); + when(pageService.getHomePage(null, 5)).thenReturn(homePageResponse()); - mockMvc.perform(get("/api/v1/activities/pages/home").param("limit", "5")) + mockMvc.perform(get("/api/v1/pages/home").param("limit", "5")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("홈 화면 활동 조회 성공")) - .andExpect(jsonPath("$.data.hero.title").value("닉네임님, 오늘은 뭐 찍먹해볼까요?")) - .andExpect(jsonPath("$.data.shortcuts[0].type").value("PROGRAM")) - .andExpect(jsonPath("$.data.recommended.activities[0].dDay").value("D-3")); - - verify(activityPageService).getHomePage(null, 5); + .andExpect(jsonPath("$.message").value("홈 페이지 조회 성공")) + .andExpect(jsonPath("$.data.user.nickname").value("tester")) + .andExpect(jsonPath("$.data.user.profileImageUrl").value("https://example.com/profile.png")) + .andExpect(jsonPath("$.data.recommendedActivities[0].hashtags.length()").value(2)) + .andExpect(jsonPath("$.data.recommendedActivities[0].deadline").value(3)) + .andExpect(jsonPath("$.data.closingSoonActivities[0].deadline").value(3)); + + verify(pageService).getHomePage(null, 5); } @Test void getCategoryPage_passesFilters() throws Exception { - when(activityPageService.getCategoryPage(null, ActivityType.PROGRAM, ActivityCategory.CRAFT, "deadline", 10)) + when(pageService.getCategoryPage(null, ActivityType.PROGRAM, ActivityCategory.CRAFT, "deadline", 10)) .thenReturn(categoryPageResponse()); - mockMvc.perform(get("/api/v1/activities/pages/category") + mockMvc.perform(get("/api/v1/pages/category") .param("type", "PROGRAM") .param("category", "CRAFT") .param("sort", "deadline") .param("limit", "10")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("카테고리 화면 활동 조회 성공")) + .andExpect(jsonPath("$.message").value("카테고리 페이지 조회 성공")) .andExpect(jsonPath("$.data.selectedType").value("PROGRAM")) - .andExpect(jsonPath("$.data.activities[0].categoryLabel").value("공예/만들기")); + .andExpect(jsonPath("$.data.typeOptions[0].label").value("전체")) + .andExpect(jsonPath("$.data.categoryOptions[1].label").value("운동 / 액티비티")) + .andExpect(jsonPath("$.data.activities[0].category").value("CRAFT")) + .andExpect(jsonPath("$.data.activities[0].hashtags.length()").value(2)); - verify(activityPageService).getCategoryPage(null, ActivityType.PROGRAM, ActivityCategory.CRAFT, "deadline", 10); + verify(pageService).getCategoryPage(null, ActivityType.PROGRAM, ActivityCategory.CRAFT, "deadline", 10); } @Test void getCustomPage_returnsTasteProfile() throws Exception { - when(activityPageService.getCustomPage(null, null)).thenReturn(customPageResponse()); + when(pageService.getCustomPage(null, null)).thenReturn(customPageResponse()); - mockMvc.perform(get("/api/v1/activities/pages/custom")) + mockMvc.perform(get("/api/v1/pages/custom")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("맞춤 화면 활동 조회 성공")) - .andExpect(jsonPath("$.data.tasteProfile.title").value("우선 한 입만 먹어보는 형")); + .andExpect(jsonPath("$.message").value("맞춤 페이지 조회 성공")) + .andExpect(jsonPath("$.data.tasteProfile.title").value("추천 활동")); - verify(activityPageService).getCustomPage(null, null); + verify(pageService).getCustomPage(null, null); } @Test void getDetailPage_returnsDisplayFields() throws Exception { - when(activityPageService.getDetailPage(null, 1L)).thenReturn(detailPageResponse()); + when(pageService.getDetailPage(null, 1L)).thenReturn(detailPageResponse()); - mockMvc.perform(get("/api/v1/activities/pages/detail/{activityId}", 1L)) + mockMvc.perform(get("/api/v1/pages/detail/{activityId}", 1L)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("상세 화면 활동 조회 성공")) + .andExpect(jsonPath("$.message").value("상세 페이지 조회 성공")) .andExpect(jsonPath("$.data.organizer").value("운영기관")) .andExpect(jsonPath("$.data.images[0].imageUrl").value("https://example.com/image.png")) - .andExpect(jsonPath("$.data.priceLabel").value("무료")); + .andExpect(jsonPath("$.data.hashtags.length()").value(3)) + .andExpect(jsonPath("$.data.deadline").value(3)); - verify(activityPageService).getDetailPage(null, 1L); + verify(pageService).getDetailPage(null, 1L); } private ActivityHomePageResponse homePageResponse() { return new ActivityHomePageResponse( - "닉네임", - new ActivityHomePageResponse.Hero("닉네임님, 오늘은 뭐 찍먹해볼까요?", "가볍게 둘러보세요.", "나만의 경험 탐색하기", "/activities/pages/custom"), - List.of(new ActivityShortcutResponse(ActivityType.PROGRAM, "프로그램", "palette", "/activities/pages/category?type=PROGRAM")), - new ActivitySectionResponse("recommended", "닉네임님에게 추천해요!", null, List.of(card())), - new ActivitySectionResponse("closingSoon", "인기 마감 임박", null, List.of(card())) + new ActivityHomePageResponse.UserResponse("tester", "https://example.com/profile.png"), + List.of(card()), + List.of(card()) ); } @@ -133,17 +136,39 @@ private ActivityCategoryPageResponse categoryPageResponse() { ActivityCategory.CRAFT, "deadline", 1L, - List.of(new ActivityFilterOptionResponse("PROGRAM", "프로그램", true)), - List.of(new ActivityFilterOptionResponse("CRAFT", "공예/만들기", true)), - List.of(new ActivityFilterOptionResponse("deadline", "마감임박순", true)), + List.of( + new ActivityFilterOptionResponse("", "전체", false), + new ActivityFilterOptionResponse("PROGRAM", "프로그램", true), + new ActivityFilterOptionResponse("ONE_DAY", "원데이", false), + new ActivityFilterOptionResponse("EVENT", "행사·강연", false), + new ActivityFilterOptionResponse("CLUB", "동아리", false) + ), + List.of( + new ActivityFilterOptionResponse("", "전체", false), + new ActivityFilterOptionResponse("SPORTS", "운동 / 액티비티", false), + new ActivityFilterOptionResponse("CULTURE", "문화 / 예술", false), + new ActivityFilterOptionResponse("CRAFT", "공예 / 만들기", true), + new ActivityFilterOptionResponse("COOKING", "요리 / 베이킹", false), + new ActivityFilterOptionResponse("PHOTO_VIDEO", "사진 / 영상", false), + new ActivityFilterOptionResponse("HUMANITIES", "책 / 글", false), + new ActivityFilterOptionResponse("TRAVEL", "여행 / 탐방", false), + new ActivityFilterOptionResponse("LANGUAGE", "언어 / 해외", false), + new ActivityFilterOptionResponse("VOLUNTEER", "봉사활동", false), + new ActivityFilterOptionResponse("CAREER", "성장 / 커리어", false) + ), + List.of( + new ActivityFilterOptionResponse("recommended", "추천순", false), + new ActivityFilterOptionResponse("popular", "인기순", false), + new ActivityFilterOptionResponse("deadline", "마감순", true) + ), List.of(card()) ); } private ActivityCustomPageResponse customPageResponse() { return new ActivityCustomPageResponse( - "닉네임", - new ActivityCustomPageResponse.TasteProfile("우선 한 입만 먹어보는 형", "취향 적합도가 높은 활동이에요!", List.of("#입문")), + "tester", + new ActivityCustomPageResponse.TasteProfile("추천 활동", "취향에 맞는 활동을 모아봤어요.", List.of("#모임")), new ActivitySectionResponse("customRecommended", "맞춤 추천 활동", null, List.of(card())) ); } @@ -156,7 +181,7 @@ private ActivityDetailPageResponse detailPageResponse() { "테스트 활동", "상세 설명", "https://example.com/thumb.png", - List.of(new ActivityImageItemResponse(1L, "https://example.com/image.png", 0, true)), + List.of(new ImageItemResponse(1L, "https://example.com/image.png", 0, true)), "https://example.com/apply", "서울", "운영기관", @@ -166,18 +191,11 @@ private ActivityDetailPageResponse detailPageResponse() { BASE_TIME.plusDays(4), BASE_TIME, BASE_TIME.plusDays(3), - "2026.06.01", - "2026.05.28 ~ 2026.05.31", - "D-3", - 3L, - "3일 남음", + 3, 0, - "무료", ActivityType.PROGRAM, - "프로그램", ActivityCategory.CRAFT, - "공예/만들기", - List.of("#공예/만들기"), + List.of("#공예 / 만들기", "#프로그램", "#사교"), SourceType.URL_MANUAL, null, ApprovalStatus.APPROVED, @@ -196,25 +214,22 @@ private ActivityCardResponse card() { 1L, "테스트 활동", "https://example.com/thumb.png", - "D-3", - 3L, - "3일 남음", + 3, 10L, "서울", "서울", ActivityType.PROGRAM, - "프로그램", ActivityCategory.CRAFT, - "공예/만들기", - List.of("#공예/만들기", "#프로그램"), + List.of("#공예 / 만들기", "#프로그램"), + true, 0, - "무료", 1, 2, 3, false, BASE_TIME.plusDays(4), BASE_TIME.plusDays(4), + BASE_TIME, BASE_TIME.plusDays(3) ); } diff --git a/src/test/java/com/jjikmeok/app/domain/page/converter/PageConverterTest.java b/src/test/java/com/jjikmeok/app/domain/page/converter/PageConverterTest.java new file mode 100644 index 0000000..7618717 --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/page/converter/PageConverterTest.java @@ -0,0 +1,91 @@ +package com.jjikmeok.app.domain.page.converter; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.activity.entity.ActivityTag; +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; +import com.jjikmeok.app.domain.activity.enums.PreferenceTag; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.image.entity.Image; +import com.jjikmeok.app.domain.region.entity.Region; +import com.jjikmeok.app.domain.region.enums.RegionDepth; +import com.jjikmeok.app.domain.tag.entity.Tag; +import com.jjikmeok.app.domain.tag.entity.TagType; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PageConverterTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 6, 25); + + @Test + void toCard_returnsTwoRandomHashtagsFromFiveCandidates() { + Activity activity = activityWithTags(); + + assertThat(PageConverter.toCard(activity, false, false, TODAY).hashtags()) + .hasSize(2) + .doesNotHaveDuplicates() + .allMatch(tag -> tag.startsWith("#")); + } + + @Test + void toDetail_returnsThreeRandomHashtagsFromFiveCandidates() { + Activity activity = activityWithTags(); + + assertThat(PageConverter.toDetail(activity, List.of(), false, TODAY).hashtags()) + .hasSize(3) + .doesNotHaveDuplicates() + .allMatch(tag -> tag.startsWith("#")); + } + + @Test + void toDetail_usesOnlyOneMoodTagFromTwoMoodTags() { + Activity activity = activityWithTags(); + + List hashtags = PageConverter.toDetail(activity, List.of(), false, TODAY).hashtags(); + + assertThat(hashtags.stream() + .filter(tag -> tag.equals(PreferenceTag.CALM.getHashtag()) || tag.equals(PreferenceTag.HEALING.getHashtag())) + .count()).isLessThanOrEqualTo(1); + } + + private Activity activityWithTags() { + Region region = Region.builder() + .name("서울") + .depth(RegionDepth.PROVINCE) + .build(); + ReflectionTestUtils.setField(region, "id", 10L); + + Activity activity = Activity.builder() + .region(region) + .title("테스트 활동") + .description("상세 설명") + .thumbnailUrl("https://example.com/thumb.png") + .sourceUrl("https://example.com/apply") + .address("서울") + .recruitStartAt(LocalDateTime.of(2026, 6, 1, 0, 0)) + .recruitEndAt(LocalDateTime.of(2026, 6, 30, 0, 0)) + .startAt(LocalDateTime.of(2026, 7, 1, 0, 0)) + .endAt(LocalDateTime.of(2026, 7, 1, 0, 0)) + .activityType(ActivityType.PROGRAM) + .category(ActivityCategory.CRAFT) + .sourceType(SourceType.URL_MANUAL) + .approvalStatus(ApprovalStatus.APPROVED) + .price(0) + .isActive(true) + .build(); + ReflectionTestUtils.setField(activity, "id", 1L); + + for (String name : List.of("편안한", "힐링", "가볍게", "취미", "단기", "소규모", "사교")) { + activity.getTags().add(ActivityTag.create(activity, Tag.create(name, TagType.PREFERENCE_TAG))); + } + return activity; + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/page/service/PageServiceImplTest.java b/src/test/java/com/jjikmeok/app/domain/page/service/PageServiceImplTest.java new file mode 100644 index 0000000..3894d8a --- /dev/null +++ b/src/test/java/com/jjikmeok/app/domain/page/service/PageServiceImplTest.java @@ -0,0 +1,109 @@ +package com.jjikmeok.app.domain.page.service; + +import com.jjikmeok.app.domain.activity.entity.Activity; +import com.jjikmeok.app.domain.activity.enums.ActivityCategory; +import com.jjikmeok.app.domain.activity.enums.ActivityType; +import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; +import com.jjikmeok.app.domain.activity.enums.SourceType; +import com.jjikmeok.app.domain.activity.repository.ActivityRepository; +import com.jjikmeok.app.domain.image.repository.ImageRepository; +import com.jjikmeok.app.domain.favorite.repository.FavoriteRepository; +import com.jjikmeok.app.domain.region.entity.Region; +import com.jjikmeok.app.domain.region.enums.RegionDepth; +import com.jjikmeok.app.domain.user.repository.UserOnboardingRegionRepository; +import com.jjikmeok.app.domain.user.repository.UserOnboardingTagRepository; +import com.jjikmeok.app.domain.user.repository.UserProfileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PageServiceImplTest { + + @Mock + private ActivityRepository activityRepository; + + @Mock + private FavoriteRepository favoriteRepository; + + @Mock + private ImageRepository imageRepository; + + @Mock + private UserProfileRepository userProfileRepository; + + @Mock + private UserOnboardingTagRepository userOnboardingTagRepository; + + @Mock + private UserOnboardingRegionRepository userOnboardingRegionRepository; + + private PageServiceImpl pageService; + + @BeforeEach + void setUp() { + pageService = new PageServiceImpl( + activityRepository, + favoriteRepository, + imageRepository, + userProfileRepository, + userOnboardingTagRepository, + userOnboardingRegionRepository + ); + } + + @Test + void getHomePage_marksTopViewedCardAsAd() { + Activity activity = activity(1L, 120); + when(activityRepository.findApprovedLatest(eq(ApprovalStatus.APPROVED), any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of(activity)); + when(activityRepository.findApprovedClosingSoon(eq(ApprovalStatus.APPROVED), any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(List.of()); + + var response = pageService.getHomePage(null, 1); + + assertThat(response.recommendedActivities()).hasSize(1); + assertThat(response.recommendedActivities().getFirst().isAd()).isTrue(); + } + + private Activity activity(Long id, int viewCount) { + Region region = Region.builder() + .name("서울") + .depth(RegionDepth.PROVINCE) + .build(); + ReflectionTestUtils.setField(region, "id", 10L); + + Activity activity = Activity.builder() + .region(region) + .title("테스트 활동") + .description("상세 설명") + .sourceUrl("https://example.com/apply") + .recruitStartAt(LocalDateTime.of(2026, 6, 1, 0, 0)) + .recruitEndAt(LocalDateTime.of(2026, 6, 30, 0, 0)) + .startAt(LocalDateTime.of(2026, 7, 1, 0, 0)) + .endAt(LocalDateTime.of(2026, 7, 1, 0, 0)) + .activityType(ActivityType.PROGRAM) + .category(ActivityCategory.CRAFT) + .sourceType(SourceType.URL_MANUAL) + .approvalStatus(ApprovalStatus.APPROVED) + .price(0) + .isActive(true) + .build(); + ReflectionTestUtils.setField(activity, "id", id); + ReflectionTestUtils.setField(activity, "viewCount", viewCount); + return activity; + } +} diff --git a/src/test/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizerTest.java b/src/test/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizerTest.java deleted file mode 100644 index a0b62c0..0000000 --- a/src/test/java/com/jjikmeok/app/domain/sync/service/ActivityNormalizerTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.jjikmeok.app.domain.sync.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; -import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.sync.dto.NormalizedActivity; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.LocalDate; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -class ActivityNormalizerTest { - - private ActivityNormalizer normalizer; - - @BeforeEach - void setUp() { - normalizer = new ActivityNormalizer(new ObjectMapper(), new CategoryClassifier(), new ActivitySyncUtils()); - } - - @Test - void normalize_parsesJsonAndXmlItems() { - String json = """ - {"response":{"body":{"items":{"item":[ - {"contentid":"t1","title":"walk","addr1":"seoul","firstimage":"thumb","eventstartdate":"20260501","eventenddate":"20260502"} - ]}}}} - """; - List tour = normalizer.normalize(SourceType.TOUR_API, "https://tour", "JSON", json); - - assertThat(tour).hasSize(1); - assertThat(tour.getFirst().externalId()).isEqualTo("t1"); - assertThat(tour.getFirst().title()).isNotBlank(); - assertThat(tour.getFirst().category()).isNotNull(); - assertThat(tour.getFirst().approvalStatus()).isEqualTo(ApprovalStatus.APPROVED); - - String xml = """ - - k1concertconcertposter - - """; - List kopis = normalizer.normalize(SourceType.KOPIS, "https://kopis", "XML", xml); - - assertThat(kopis).hasSize(1); - assertThat(kopis.getFirst().externalId()).isEqualTo("k1"); - assertThat(kopis.getFirst().thumbnailUrl()).isEqualTo("poster"); - assertThat(kopis.getFirst().category()).isNotNull(); - } - - @Test - void normalize_mapsUppercaseAndPeriodFields() { - String payload = """ - {"data":[{"LOCAL_ID":"e1","TITLE":"photo","DESCRIPTION":"desc","IMAGE_OBJECT":"img", - "URL":"https://exhibition","EVENT_SITE":"gallery","PERIOD":"2026.05.01 ~ 2026.05.03","CHARGE":"5,000"}]} - """; - - NormalizedActivity activity = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", payload).getFirst(); - - assertThat(activity.externalId()).isEqualTo("e1"); - assertThat(activity.title()).isNotBlank(); - assertThat(activity.sourceUrl()).isEqualTo("https://exhibition"); - assertThat(activity.category()).isNotNull(); - assertThat(activity.startAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 1)); - assertThat(activity.endAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 3)); - } - - @Test - void normalize_unescapesTitleAndUsesFirstPriceAmount() { - String payload = """ - {"data":[{"LOCAL_ID":"e2","TITLE":"Title <Sub>","URL":"https://example.com","CHARGE":"adult 10,000 / youth 7,000"}]} - """; - - NormalizedActivity activity = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", payload).getFirst(); - - assertThat(activity.title()).isNotBlank(); - assertThat(activity.sourceUrl()).isNotBlank(); - } - - @Test - void normalize_repairsMojibakeAndFiltersInvalidPlaceMetadata() { - String payload = """ - {"data":[{"LOCAL_ID":"e3","TITLE":"title","PLACE":"seoul","ORG_NAME":"invalid metadata text"}]} - """; - - NormalizedActivity activity = normalizer.normalize(SourceType.EXHIBITION, "https://api", "JSON", payload).getFirst(); - - assertThat(activity.title()).isNotBlank(); - assertThat(activity.organizer()).isNotNull(); - } - - @Test - void normalize_parsesKopisDbsDb() { - String xml = """ - - PF1music2026.05.24 - 2026.06.24hallposter - music공연중 - - PF2theater20260525 - 20260625artposter2 - theater공연완료 - - """; - - List activities = normalizer.normalize(SourceType.KOPIS, "https://kopis", "XML", xml); - assertThat(activities).hasSize(2); - assertThat(activities.getFirst().externalId()).isEqualTo("PF1"); - assertThat(activities.get(1).externalId()).isEqualTo("PF2"); - assertThat(activities.get(1).startAt().toLocalDate()).isEqualTo(LocalDate.of(2026, 5, 25)); - } - - @Test - void normalize_parsesYouthContentResponse() { - String payload = """ - {"resultCode":200,"result":{"youthPolicyList":[ - {"bbsSn":"48","pstSn":"10580","pstSeNm":"job","pstTtl":"AI program", - "pstWholCn":"

apply

","atchFile":"data:image/jpeg;base64,abcdef"} - ]}} - """; - - NormalizedActivity activity = normalizer.normalize(SourceType.YOUTH_CONTENT, "https://api", "JSON", payload).getFirst(); - assertThat(activity.externalId()).isEqualTo("48:10580"); - assertThat(activity.title()).isNotBlank(); - assertThat(activity.description()).isNotBlank(); - assertThat(activity.sourceUrl()).isNotBlank(); - assertThat(activity.thumbnailUrl()).startsWith("data:image/jpeg;base64,"); - assertThat(activity.category()).isNotNull(); - assertThat(activity.activityType()).isNotNull(); - } - - @Test - void normalize_filtersYouthNoticeNewsAndTips() { - String payload = """ - {"result":{"youthPolicyList":[ - {"bbsSn":"46","pstSn":"1","pstSeNm":"notice","pstTtl":"notice title","pstWholCn":"content"}, - {"bbsSn":"54","pstSn":"2","pstSeNm":"news","pstTtl":"news title","pstWholCn":"content"}, - {"bbsSn":"48","pstSn":"3","pstSeNm":"job","pstTtl":"program recruit","pstWholCn":"apply"} - ]}} - """; - - List activities = normalizer.normalize(SourceType.YOUTH_CONTENT, "https://api", "JSON", payload); - assertThat(activities).isNotEmpty(); - assertThat(activities.getFirst().externalId()).isNotBlank(); - } -} diff --git a/src/test/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImplTest.java b/src/test/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImplTest.java deleted file mode 100644 index 5346c46..0000000 --- a/src/test/java/com/jjikmeok/app/domain/sync/service/ActivitySyncServiceImplTest.java +++ /dev/null @@ -1,345 +0,0 @@ -package com.jjikmeok.app.domain.sync.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jjikmeok.app.domain.activity.entity.Activity; -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.enums.ApprovalStatus; -import com.jjikmeok.app.domain.activity.enums.SourceType; -import com.jjikmeok.app.domain.activity.repository.ActivityRepository; -import com.jjikmeok.app.domain.ai.dto.ExtractedActivityDto; -import com.jjikmeok.app.domain.ai.service.AiActivityParser; -import com.jjikmeok.app.domain.sync.dto.ActivitySyncResponse; -import com.jjikmeok.app.domain.sync.dto.NormalizedActivity; -import com.jjikmeok.app.domain.sync.entity.RawActivity; -import com.jjikmeok.app.domain.sync.repository.RawActivityRepository; -import com.jjikmeok.app.domain.region.entity.Region; -import com.jjikmeok.app.domain.region.enums.RegionDepth; -import com.jjikmeok.app.domain.region.repository.RegionRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.quality.Strictness; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.LocalDateTime; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class ActivitySyncServiceImplTest { - - @Mock - private ActivityRegionResolver activityRegionResolver; - @Mock - private ExternalActivityGateway externalActivityGateway; - @Mock - private ActivityNormalizer activityNormalizer; - @Mock - private RawActivityRepository rawActivityRepository; - @Mock - private ActivityRepository activityRepository; - @Mock - private RegionRepository regionRepository; - @Mock - private ActivityAttachmentStorageService activityAttachmentStorageService; - @Mock - private ActivityDetailEnricher activityDetailEnricher; - - // 신규 도메인 및 벡터스토어 의존성 Mock 선언 - @Mock - private AiActivityParser aiActivityParser; - @Mock - private VectorStore vectorStore; - - private ActivitySyncUtils activitySyncUtils; - private ActivitySyncServiceImpl service; - - @BeforeEach - void setUp() { - activitySyncUtils = new ActivitySyncUtils(); - - // 11개의 파라미터 규격 생성자 매핑 완료 - service = new ActivitySyncServiceImpl( - activityRegionResolver, - externalActivityGateway, - activityNormalizer, - rawActivityRepository, - activityRepository, - regionRepository, - new ObjectMapper(), - activityAttachmentStorageService, - activityDetailEnricher, - activitySyncUtils, - aiActivityParser, - vectorStore - ); - - // 🌟 잘려 있던 Reflection 주입부를 setUp 블록 내부로 완전 격리 조정 - ReflectionTestUtils.setField(service, "defaultRegionId", 1L); - ReflectionTestUtils.setField(service, "defaultMaxPages", 1); - ReflectionTestUtils.setField(service, "monthsAhead", 1); - ReflectionTestUtils.setField(service, "tourApiBaseUrl", "https://tour"); - ReflectionTestUtils.setField(service, "tourApiServiceKey", ""); - ReflectionTestUtils.setField(service, "tourApiMaxPages", 1); - ReflectionTestUtils.setField(service, "serverBaseUrl", "http://localhost:8080"); - - Region defaultRegion = Region.builder().name("default").depth(RegionDepth.PROVINCE).build(); - when(activityRegionResolver.resolve(any(), any(), any())).thenReturn(defaultRegion); - } - - @Test - void sync_updatesDuplicateActivity() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - Activity existing = Activity.builder() - .region(region) - .title("기존") - .description("기존 설명") - .sourceUrl("https://old") - .address("예술극장") - .recruitStartAt(startAt.minusDays(2)) - .recruitEndAt(startAt) - .activityType(ActivityType.EVENT) - .category(ActivityCategory.CULTURE) - .sourceType(SourceType.TOUR_API) - .externalId("k1") - .approvalStatus(ApprovalStatus.APPROVED) - .build(); - - // 1순위 필수 조건 충족 데이터 구성 (모집 시작일 패딩) - NormalizedActivity normalized = new NormalizedActivity( - "새 공연", "새 설명", "poster", "https://old", "예술극장", - null, null, null, - startAt, startAt.plusDays(1), startAt.minusDays(2), startAt.minusDays(1), - 0, ActivityType.EVENT, ActivityCategory.CULTURE, SourceType.TOUR_API, - "k1", ApprovalStatus.APPROVED, true - ); - - when(externalActivityGateway.fetchPage(eq(SourceType.TOUR_API), eq("https://tour"), eq(""), any(LocalDate.class), any(LocalDate.class), eq(1))) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(SourceType.TOUR_API, "https://tour", "JSON", "{}")).thenReturn(List.of(normalized)); - doReturn(Optional.of(existing)) - .when(activityRepository) - .findDuplicate(eq(SourceType.TOUR_API), eq("k1"), eq("https://old"), any(), eq(startAt), any()); - - ActivitySyncResponse response = service.sync(SourceType.TOUR_API, null); - - assertThat(response.activitySavedCount()).isZero(); - assertThat(response.duplicatedCount()).isEqualTo(1); - assertThat(existing.getTitle()).isEqualTo("새 공연"); - assertThat(existing.getThumbnailUrl()).isEqualTo("poster"); - verify(activityRepository, never()).save(any(Activity.class)); - } - - @Test - void sync_skipsDuplicateWhenSame() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - LocalDateTime endAt = startAt.plusDays(1); - LocalDateTime recruitStartAt = startAt.minusDays(2); - LocalDateTime recruitEndAt = startAt.minusDays(1); - - Activity existing = spy(Activity.builder() - .region(region) - .title("공연") - .description("설명") - .thumbnailUrl("poster") - .sourceUrl("https://old") - .address("예술극장") - .startAt(startAt) - .endAt(endAt) - .recruitStartAt(recruitStartAt) - .recruitEndAt(recruitEndAt) - .price(0) - .activityType(ActivityType.EVENT) - .category(ActivityCategory.CULTURE) - .sourceType(SourceType.TOUR_API) - .externalId("k1") - .approvalStatus(ApprovalStatus.APPROVED) - .isActive(true) - .build()); - - NormalizedActivity normalized = new NormalizedActivity( - "공연", "설명", "poster", "https://old", "예술극장", - null, null, null, startAt, endAt, recruitStartAt, recruitEndAt, 0, - ActivityType.EVENT, ActivityCategory.CULTURE, SourceType.TOUR_API, "k1", ApprovalStatus.APPROVED, true - ); - - when(externalActivityGateway.fetchPage(eq(SourceType.TOUR_API), eq("https://tour"), eq(""), any(LocalDate.class), any(LocalDate.class), eq(1))) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(SourceType.TOUR_API, "https://tour", "JSON", "{}")).thenReturn(List.of(normalized)); - doReturn(Optional.of(existing)) - .when(activityRepository) - .findDuplicate(eq(SourceType.TOUR_API), eq("k1"), eq("https://old"), any(), eq(startAt), any()); - - ActivitySyncResponse response = service.sync(SourceType.TOUR_API, null); - - assertThat(response.duplicatedCount()).isEqualTo(1); - verify(existing, never()).updateExtra(any(), any(), any()); - verify(activityRepository, never()).save(any(Activity.class)); - } - - @Test - void sync_savesLongValuesForTextColumns() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - String title = "가".repeat(150); - String thumbnailUrl = "https://example.com/" + "t".repeat(600); - String sourceUrl = "https://example.com/" + "s".repeat(600); - String address = "주소".repeat(150); - String organizer = "기관".repeat(80); - String contactInfo = "연락처".repeat(100); - String target = "대상".repeat(150); - String externalId = "external".repeat(30); - - NormalizedActivity normalized = new NormalizedActivity( - title, "설명", thumbnailUrl, sourceUrl, address, organizer, contactInfo, target, - startAt, startAt.plusDays(1), startAt.minusDays(2), startAt.minusDays(1), - 0, ActivityType.PROGRAM, ActivityCategory.CULTURE, SourceType.TOUR_API, - externalId, ApprovalStatus.APPROVED, true - ); - - when(externalActivityGateway.fetchPage(eq(SourceType.TOUR_API), eq("https://tour"), eq(""), any(LocalDate.class), any(LocalDate.class), eq(1))) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(SourceType.TOUR_API, "https://tour", "JSON", "{}")).thenReturn(List.of(normalized)); - when(activityRepository.findDuplicate(eq(SourceType.TOUR_API), any(), any(), any(), eq(startAt), any())).thenReturn(Optional.empty()); - - service.sync(SourceType.TOUR_API, null, 1); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Activity.class); - verify(activityRepository).save(captor.capture()); - Activity saved = captor.getValue(); - assertThat(saved.getTitle()).isEqualTo(title); - } - - // 🌟 [비즈니스 정렬 동기화 반영] 2순위 데이터 부재 시 기본 문장값(Default Text) 패딩 메커니즘 정상 작동 검증 - @Test - void sync_fillsSecondPriorityFieldsWithDefaultsWhenMissing() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - - NormalizedActivity normalized = new NormalizedActivity( - "정상 메타 활동", null, "https://thumb.png", "https://example.com/core", "체육관", - null, null, null, // 2순위 정보 전무 - startAt, startAt.plusDays(2), startAt.minusDays(2), startAt.minusDays(1), - 10000, ActivityType.PROGRAM, ActivityCategory.SPORTS, SourceType.TOUR_API, - "core-id", ApprovalStatus.APPROVED, true - ); - - when(externalActivityGateway.fetchPage(eq(SourceType.TOUR_API), eq("https://tour"), eq(""), any(LocalDate.class), any(LocalDate.class), eq(1))) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(SourceType.TOUR_API, "https://tour", "JSON", "{}")).thenReturn(List.of(normalized)); - when(activityRepository.findDuplicate(eq(SourceType.TOUR_API), eq("core-id"), any(), any(), eq(startAt), any())) - .thenReturn(Optional.empty()); - - service.sync(SourceType.TOUR_API, null, 1); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Activity.class); - verify(activityRepository).save(captor.capture()); - Activity saved = captor.getValue(); - - // 2순위 필드가 누락되었을 때 하네스가 기본 문장값으로 수혈했는지 검증 - assertThat(saved.getDescription()).isEqualTo("상세 설명은 원문에서 확인하세요."); - assertThat(saved.getOrganizer()).isEqualTo("주최기관 정보는 원문 링크를 확인하세요."); - assertThat(saved.getContactInfo()).isEqualTo("문의 안내는 원문 링크를 확인하세요."); - assertThat(saved.getTarget()).isEqualTo("참여 대상은 원문 링크를 확인하세요."); - assertThat(saved.getIsActive()).isTrue(); // 1순위 일정이 확보되었으므로 활성화 성공 - } - - // 🌟 [신규 검증 아티팩트] 1순위 날짜 결손 감지 시 AI 보완 레이어 파서 연동 호출 확인 테스트 - @Test - void sync_triggersAiFallbackParserWhen1stPriorityFieldsAreDeficient() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - LocalDateTime startAt = LocalDate.now().plusDays(7).atTime(10, 0); - - // 가격 및 일정 데이터가 꼬여서 유실된 불량 객체 인입 시뮬레이션 - NormalizedActivity deficientNormalized = new NormalizedActivity( - "결손된 데이터 항목", "본문내용", "https://thumb.png", "https://example.com/err", "장소", - null, null, null, - null, null, null, null, null, // 1순위 일정 및 금액 유실됨 - ActivityType.EVENT, ActivityCategory.ETC, SourceType.TOUR_API, - "def-id", ApprovalStatus.APPROVED, true - ); - - ExtractedActivityDto mockedAiResult = new ExtractedActivityDto( - startAt.minusDays(2), startAt.minusDays(1), startAt, startAt.plusDays(3), 5000, - "AI가 복원한 설명", "AI가 복원한 대상", "AI 연락처", "AI 주최사" - ); - - when(externalActivityGateway.fetchPage(any(), any(), any(), any(), any(), anyInt())) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(any(), any(), any(), any())).thenReturn(List.of(deficientNormalized)); - - // 하네스 가드 발동에 따른 AI Mock 추론 바인딩 - when(aiActivityParser.parseFallback(any(), any(SourceType.class))).thenReturn(mockedAiResult); - - service.sync(SourceType.TOUR_API, null, 1); - - // 하네스가 차단하지 않고 AI 데이터를 흡수해서 완벽하게 적재했는지 추적 - verify(aiActivityParser).parseFallback(any(), any(SourceType.class)); - verify(activityRepository).save(any(Activity.class)); - } - - @Test - void sync_savesAlwaysOpenDatesWhenDatesAreStillMissingAfterScraping() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - NormalizedActivity normalized = new NormalizedActivity( - "상시 활동", "본문내용", "https://thumb.png", "https://example.com/always", "장소", - "기관", "010-1234-5678", "성인", null, null, null, null, null, - ActivityType.EVENT, ActivityCategory.ETC, SourceType.TOUR_API, - "always-id", ApprovalStatus.APPROVED, true - ); - - when(externalActivityGateway.fetchPage(any(), any(), any(), any(), any(), anyInt())) - .thenReturn(new ExternalActivityGateway.FetchedPayload(SourceType.TOUR_API, "https://tour", "JSON", "{}")); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - when(activityNormalizer.normalize(any(), any(), any(), any())).thenReturn(List.of(normalized)); - when(aiActivityParser.parseFallback(any(), any(SourceType.class))).thenReturn(null); - when(activityRepository.findDuplicate(eq(SourceType.TOUR_API), eq("always-id"), any(), any(), any(), any())) - .thenReturn(Optional.empty()); - - service.sync(SourceType.TOUR_API, null, 1); - - ArgumentCaptor captor = ArgumentCaptor.forClass(Activity.class); - verify(activityRepository).save(captor.capture()); - assertThat(captor.getValue().getRecruitEndAt().getYear()).isEqualTo(2999); - assertThat(captor.getValue().getStartAt().getYear()).isEqualTo(2999); - } - - @Test - void sync_skipsExternalCallsWhenMaxPagesIsZero() { - Region region = Region.builder().name("서울").depth(RegionDepth.PROVINCE).build(); - when(regionRepository.findById(1L)).thenReturn(Optional.of(region)); - - ActivitySyncResponse response = service.sync(SourceType.TOUR_API, null, 0); - - assertThat(response.rawSavedCount()).isZero(); - assertThat(response.activitySavedCount()).isZero(); - verify(externalActivityGateway, never()).fetchPage(any(), any(), any(), any(), any(), anyInt()); - verify(rawActivityRepository, never()).save(any()); - verify(activityRepository, never()).save(any()); - } -} diff --git a/src/test/java/com/jjikmeok/app/domain/sync/service/CategoryClassifierTest.java b/src/test/java/com/jjikmeok/app/domain/sync/service/CategoryClassifierTest.java deleted file mode 100644 index dd87163..0000000 --- a/src/test/java/com/jjikmeok/app/domain/sync/service/CategoryClassifierTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.jjikmeok.app.domain.sync.service; - -import com.jjikmeok.app.domain.activity.enums.ActivityCategory; -import com.jjikmeok.app.domain.activity.enums.ActivityType; -import com.jjikmeok.app.domain.activity.enums.SourceType; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; - -class CategoryClassifierTest { - - private final CategoryClassifier classifier = new CategoryClassifier(); - - @Test - void classifyCategory_bySourceAndKeywords() { - assertThat(classifier.classifyCategory(SourceType.VOLUNTEER_1365, "모집")).isEqualTo(ActivityCategory.VOLUNTEER); - assertThat(classifier.classifyCategory(SourceType.KOPIS, "국악 콘서트")).isEqualTo(ActivityCategory.MUSIC); - assertThat(classifier.classifyCategory(SourceType.EXHIBITION, "도예 전시")).isEqualTo(ActivityCategory.CRAFT); - assertThat(classifier.classifyCategory(SourceType.YOUTH_CONTENT, "직업훈련 채용 실무 과정")).isEqualTo(ActivityCategory.CAREER); - } - - @Test - void classifyActivityType_byKeywordsAndReservationDate() { - LocalDateTime date = LocalDateTime.of(2026, 5, 1, 10, 0); - - assertThat(classifier.classifyType(SourceType.URL_MANUAL, "러닝크루 모임")).isEqualTo(ActivityType.CLUB); - assertThat(classifier.classifyType(SourceType.URL_MANUAL, "원데이 클래스")).isEqualTo(ActivityType.PROGRAM); - assertThat(classifier.classifyType(SourceType.URL_MANUAL, "원데이 클래스", date, date.plusHours(2))).isEqualTo(ActivityType.ONE_DAY); - assertThat(classifier.classifyType(SourceType.URL_MANUAL, "공연 행사", date, date.plusDays(2))).isEqualTo(ActivityType.EVENT); - assertThat(classifier.classifyType(SourceType.SEOUL_RESERVATION, "예약", date, date.plusHours(2))).isEqualTo(ActivityType.ONE_DAY); - } -} diff --git a/src/test/java/com/jjikmeok/app/domain/tag/service/TagServiceImplTest.java b/src/test/java/com/jjikmeok/app/domain/tag/service/TagServiceImplTest.java index 58c8251..e541c79 100644 --- a/src/test/java/com/jjikmeok/app/domain/tag/service/TagServiceImplTest.java +++ b/src/test/java/com/jjikmeok/app/domain/tag/service/TagServiceImplTest.java @@ -70,21 +70,30 @@ void getTags_withType_returnsFilteredTags() { @Test void getPreferenceTagGroups_returnsPreferenceTagsGroupedByTaxonomy() { when(tagRepository.findAllByTypeOrderByNameAsc(TagType.PREFERENCE_TAG)).thenReturn(List.of( - tag(1L, "차분", TagType.PREFERENCE_TAG), - tag(2L, "활기", TagType.PREFERENCE_TAG), - tag(3L, "무료", TagType.PREFERENCE_TAG), - tag(4L, "하루", TagType.PREFERENCE_TAG) + tag(1L, "편안한", TagType.PREFERENCE_TAG), + tag(2L, "활기찬", TagType.PREFERENCE_TAG), + tag(3L, "사교", TagType.PREFERENCE_TAG), + tag(4L, "단기", TagType.PREFERENCE_TAG), + tag(5L, "입문", TagType.PREFERENCE_TAG), + tag(6L, "소규모", TagType.PREFERENCE_TAG) )); List responses = tagService.getPreferenceTagGroups(); assertThat(responses).extracting(PreferenceTagGroupResponse::group) - .containsExactly(PreferenceTagGroup.MOOD, PreferenceTagGroup.INTENSITY, PreferenceTagGroup.PRICE, - PreferenceTagGroup.PURPOSE, PreferenceTagGroup.DURATION); - assertThat(responses.getFirst().label()).isEqualTo("활동 분위기"); - assertThat(responses.getFirst().tags()).extracting(TagResponse::name).containsExactly("차분", "활기"); - assertThat(responses.get(2).tags()).extracting(TagResponse::name).containsExactly("무료"); - assertThat(responses.get(4).tags()).extracting(TagResponse::name).containsExactly("하루"); + .containsExactly( + PreferenceTagGroup.MOOD, + PreferenceTagGroup.INTENSITY, + PreferenceTagGroup.PURPOSE, + PreferenceTagGroup.DURATION, + PreferenceTagGroup.SIZE + ); + assertThat(responses.getFirst().label()).isEqualTo("분위기 태그"); + assertThat(responses.getFirst().tags()).extracting(TagResponse::name).containsExactly("편안한", "활기찬"); + assertThat(responses.get(1).tags()).extracting(TagResponse::name).containsExactly("입문"); + assertThat(responses.get(2).tags()).extracting(TagResponse::name).containsExactly("사교"); + assertThat(responses.get(3).tags()).extracting(TagResponse::name).containsExactly("단기"); + assertThat(responses.get(4).tags()).extracting(TagResponse::name).containsExactly("소규모"); } @Test @@ -137,23 +146,23 @@ void createTag_whenSaveConflicts_throwsDuplicateName() { @Test void updateTag_updatesNameAndType() { Tag tag = tag(1L, "프로그램", TagType.ACTIVITY_CATEGORY); - TagRequest request = new TagRequest(" 운동/액티비티 ", TagType.TOPIC_CATEGORY); + TagRequest request = new TagRequest(" 운동 / 액티비티 ", TagType.TOPIC_CATEGORY); when(tagRepository.findById(1L)).thenReturn(Optional.of(tag)); - when(tagRepository.existsByNameAndTypeAndIdNot("운동/액티비티", TagType.TOPIC_CATEGORY, 1L)).thenReturn(false); + when(tagRepository.existsByNameAndTypeAndIdNot("운동 / 액티비티", TagType.TOPIC_CATEGORY, 1L)).thenReturn(false); when(tagRepository.saveAndFlush(tag)).thenReturn(tag); TagResponse response = tagService.updateTag(1L, request); - assertThat(response.name()).isEqualTo("운동/액티비티"); + assertThat(response.name()).isEqualTo("운동 / 액티비티"); assertThat(response.type()).isEqualTo(TagType.TOPIC_CATEGORY); } @Test void updateTag_whenFlushConflicts_throwsDuplicateName() { Tag tag = tag(1L, "프로그램", TagType.ACTIVITY_CATEGORY); - TagRequest request = new TagRequest(" 운동/액티비티 ", TagType.TOPIC_CATEGORY); + TagRequest request = new TagRequest(" 운동 / 액티비티 ", TagType.TOPIC_CATEGORY); when(tagRepository.findById(1L)).thenReturn(Optional.of(tag)); - when(tagRepository.existsByNameAndTypeAndIdNot("운동/액티비티", TagType.TOPIC_CATEGORY, 1L)).thenReturn(false); + when(tagRepository.existsByNameAndTypeAndIdNot("운동 / 액티비티", TagType.TOPIC_CATEGORY, 1L)).thenReturn(false); when(tagRepository.saveAndFlush(tag)).thenThrow(new DataIntegrityViolationException("duplicate")); CustomException exception = assertThrows(CustomException.class, () -> tagService.updateTag(1L, request));