Skip to content

Commit a3e85f8

Browse files
committed
Refactor: Decouple FeedSource from CrawlingDsl and add Admin API
1 parent 4a08b0c commit a3e85f8

7 files changed

Lines changed: 274 additions & 165 deletions

File tree

.github/workflows/dev-deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: Dev Deployment
22

33
on:
44
push:
5-
branches: [ develop, fix/fcm-multi-404 ]
5+
branches: [ develop, feat/feed ]
66

77
jobs:
88
build:
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.linglevel.api.admin.feed;
2+
3+
import com.linglevel.api.common.dto.ExceptionResponse;
4+
import com.linglevel.api.common.dto.MessageResponse;
5+
import com.linglevel.api.content.feed.dto.CreateFeedSourceRequest;
6+
import com.linglevel.api.content.feed.dto.FeedSourceResponse;
7+
import com.linglevel.api.content.feed.entity.FeedSource;
8+
import com.linglevel.api.content.feed.repository.FeedSourceRepository;
9+
import com.linglevel.api.content.feed.scheduler.FeedCrawlingScheduler;
10+
import com.linglevel.api.crawling.service.CrawlingService;
11+
import io.swagger.v3.oas.annotations.Operation;
12+
import io.swagger.v3.oas.annotations.Parameter;
13+
import io.swagger.v3.oas.annotations.media.Content;
14+
import io.swagger.v3.oas.annotations.media.Schema;
15+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
16+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
17+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
18+
import io.swagger.v3.oas.annotations.tags.Tag;
19+
import jakarta.validation.Valid;
20+
import lombok.RequiredArgsConstructor;
21+
import lombok.extern.slf4j.Slf4j;
22+
import org.springframework.http.HttpStatus;
23+
import org.springframework.http.ResponseEntity;
24+
import org.springframework.web.bind.annotation.*;
25+
26+
import java.time.Instant;
27+
import java.util.List;
28+
import java.util.Map;
29+
import java.util.stream.Collectors;
30+
31+
@RestController
32+
@RequestMapping("/api/v1/admin/feed-sources")
33+
@RequiredArgsConstructor
34+
@Slf4j
35+
@Tag(name = "Admin - FeedSource", description = "어드민 FeedSource 관리 API")
36+
@SecurityRequirement(name = "adminApiKey")
37+
public class AdminFeedSourceController {
38+
39+
private final FeedSourceRepository feedSourceRepository;
40+
private final FeedCrawlingScheduler feedCrawlingScheduler;
41+
private final CrawlingService crawlingService;
42+
43+
@Operation(summary = "FeedSource 생성", description = "새로운 FeedSource를 등록합니다.")
44+
@ApiResponses(value = {
45+
@ApiResponse(responseCode = "201", description = "생성 성공", useReturnTypeSchema = true),
46+
@ApiResponse(responseCode = "400", description = "잘못된 요청",
47+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
48+
@ApiResponse(responseCode = "401", description = "인증 실패",
49+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
50+
})
51+
@PostMapping
52+
public ResponseEntity<FeedSourceResponse> createFeedSource(@Valid @RequestBody CreateFeedSourceRequest request) {
53+
log.info("Creating FeedSource: {}", request.getName());
54+
55+
String domain = crawlingService.isValidUrl(request.getUrl())
56+
? extractDomain(request.getUrl())
57+
: null;
58+
59+
if (domain == null) {
60+
throw new IllegalArgumentException("Invalid URL format");
61+
}
62+
63+
FeedSource feedSource = FeedSource.builder()
64+
.url(request.getUrl())
65+
.domain(domain)
66+
.name(request.getName())
67+
.titleDsl(request.getTitleDsl())
68+
.coverImageDsl(request.getCoverImageDsl())
69+
.contentType(request.getContentType())
70+
.category(request.getCategory())
71+
.tags(request.getTags())
72+
.isActive(true)
73+
.createdAt(Instant.now())
74+
.updatedAt(Instant.now())
75+
.build();
76+
77+
FeedSource saved = feedSourceRepository.save(feedSource);
78+
log.info("FeedSource created: {} ({})", saved.getName(), saved.getId());
79+
80+
return ResponseEntity.status(HttpStatus.CREATED).body(mapToResponse(saved));
81+
}
82+
83+
@Operation(summary = "FeedSource 목록 조회", description = "등록된 모든 FeedSource를 조회합니다.")
84+
@ApiResponses(value = {
85+
@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true),
86+
@ApiResponse(responseCode = "401", description = "인증 실패",
87+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
88+
})
89+
@GetMapping
90+
public ResponseEntity<List<FeedSourceResponse>> getAllFeedSources() {
91+
List<FeedSource> feedSources = feedSourceRepository.findAll();
92+
List<FeedSourceResponse> responses = feedSources.stream()
93+
.map(this::mapToResponse)
94+
.collect(Collectors.toList());
95+
return ResponseEntity.ok(responses);
96+
}
97+
98+
@Operation(summary = "FeedSource 단건 조회", description = "특정 FeedSource를 조회합니다.")
99+
@ApiResponses(value = {
100+
@ApiResponse(responseCode = "200", description = "조회 성공", useReturnTypeSchema = true),
101+
@ApiResponse(responseCode = "401", description = "인증 실패",
102+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
103+
@ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음",
104+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
105+
})
106+
@GetMapping("/{id}")
107+
public ResponseEntity<FeedSourceResponse> getFeedSource(@PathVariable String id) {
108+
FeedSource feedSource = feedSourceRepository.findById(id)
109+
.orElseThrow(() -> new IllegalArgumentException("FeedSource not found: " + id));
110+
return ResponseEntity.ok(mapToResponse(feedSource));
111+
}
112+
113+
@Operation(summary = "FeedSource 삭제", description = "특정 FeedSource를 삭제합니다.")
114+
@ApiResponses(value = {
115+
@ApiResponse(responseCode = "200", description = "삭제 성공",
116+
content = @Content(schema = @Schema(implementation = MessageResponse.class))),
117+
@ApiResponse(responseCode = "401", description = "인증 실패",
118+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
119+
@ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음",
120+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
121+
})
122+
@DeleteMapping("/{id}")
123+
public ResponseEntity<MessageResponse> deleteFeedSource(@PathVariable String id) {
124+
if (!feedSourceRepository.existsById(id)) {
125+
throw new IllegalArgumentException("FeedSource not found: " + id);
126+
}
127+
feedSourceRepository.deleteById(id);
128+
log.info("FeedSource deleted: {}", id);
129+
return ResponseEntity.ok(new MessageResponse("FeedSource deleted successfully"));
130+
}
131+
132+
@Operation(summary = "전체 크롤링 실행", description = "모든 활성화된 FeedSource를 크롤링합니다.")
133+
@ApiResponses(value = {
134+
@ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true),
135+
@ApiResponse(responseCode = "401", description = "인증 실패",
136+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
137+
})
138+
@PostMapping("/crawl-all")
139+
public ResponseEntity<Map<String, Integer>> triggerCrawlAll() {
140+
log.info("Manual crawl-all triggered");
141+
int count = feedCrawlingScheduler.crawlAllSources();
142+
return ResponseEntity.ok(Map.of("crawledCount", count));
143+
}
144+
145+
@Operation(summary = "개별 크롤링 실행", description = "특정 FeedSource를 크롤링합니다.")
146+
@ApiResponses(value = {
147+
@ApiResponse(responseCode = "200", description = "크롤링 성공", useReturnTypeSchema = true),
148+
@ApiResponse(responseCode = "401", description = "인증 실패",
149+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))),
150+
@ApiResponse(responseCode = "404", description = "FeedSource를 찾을 수 없음",
151+
content = @Content(schema = @Schema(implementation = ExceptionResponse.class)))
152+
})
153+
@PostMapping("/{id}/crawl")
154+
public ResponseEntity<Map<String, Integer>> triggerCrawlSingle(
155+
@Parameter(description = "크롤링할 FeedSource ID") @PathVariable String id) {
156+
log.info("Manual crawl triggered for FeedSource: {}", id);
157+
int count = feedCrawlingScheduler.crawlSingleSource(id);
158+
return ResponseEntity.ok(Map.of("crawledCount", count));
159+
}
160+
161+
private FeedSourceResponse mapToResponse(FeedSource feedSource) {
162+
return FeedSourceResponse.builder()
163+
.id(feedSource.getId())
164+
.url(feedSource.getUrl())
165+
.domain(feedSource.getDomain())
166+
.name(feedSource.getName())
167+
.titleDsl(feedSource.getTitleDsl())
168+
.coverImageDsl(feedSource.getCoverImageDsl())
169+
.contentType(feedSource.getContentType())
170+
.category(feedSource.getCategory())
171+
.tags(feedSource.getTags())
172+
.isActive(feedSource.getIsActive())
173+
.createdAt(feedSource.getCreatedAt())
174+
.updatedAt(feedSource.getUpdatedAt())
175+
.build();
176+
}
177+
178+
private String extractDomain(String url) {
179+
try {
180+
java.net.URL parsedUrl = new java.net.URL(url);
181+
String host = parsedUrl.getHost().toLowerCase();
182+
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("([^.]+\\.[^.]+)$");
183+
java.util.regex.Matcher matcher = pattern.matcher(host);
184+
return matcher.find() ? matcher.group(1) : host;
185+
} catch (Exception e) {
186+
return null;
187+
}
188+
}
189+
}

src/main/java/com/linglevel/api/content/feed/dto/AdminFeedSourceController.java

Lines changed: 0 additions & 152 deletions
This file was deleted.

src/main/java/com/linglevel/api/content/feed/dto/CreateFeedSourceRequest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.linglevel.api.content.common.ContentCategory;
44
import com.linglevel.api.content.feed.entity.FeedContentType;
5+
import io.swagger.v3.oas.annotations.media.Schema;
56
import jakarta.validation.constraints.NotBlank;
67
import jakarta.validation.constraints.NotNull;
78
import lombok.AllArgsConstructor;
@@ -15,19 +16,32 @@
1516
@Builder
1617
@NoArgsConstructor
1718
@AllArgsConstructor
19+
@Schema(description = "FeedSource 생성 요청")
1820
public class CreateFeedSourceRequest {
1921

2022
@NotBlank(message = "URL is required")
23+
@Schema(description = "크롤링할 URL", example = "https://www.bbc.com/news/technology")
2124
private String url;
2225

2326
@NotBlank(message = "Name is required")
27+
@Schema(description = "FeedSource 이름", example = "BBC Technology News")
2428
private String name;
2529

30+
@NotBlank(message = "Title DSL is required")
31+
@Schema(description = "제목 추출 DSL", example = "doc > h1.title")
32+
private String titleDsl;
33+
34+
@Schema(description = "커버 이미지 추출 DSL (선택)", example = "doc > img.cover @ src")
35+
private String coverImageDsl;
36+
2637
@NotNull(message = "Content type is required")
38+
@Schema(description = "콘텐츠 타입", example = "NEWS")
2739
private FeedContentType contentType;
2840

2941
@NotNull(message = "Category is required")
42+
@Schema(description = "카테고리", example = "TECH")
3043
private ContentCategory category;
3144

45+
@Schema(description = "태그 목록", example = "[\"Technology\", \"AI\", \"News\"]")
3246
private List<String> tags;
3347
}

0 commit comments

Comments
 (0)