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
|||||", "\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 = """
+