diff --git a/.github/workflows/prod-server.yml b/.github/workflows/prod-server.yml index 4a06c1cc..d4062802 100644 --- a/.github/workflows/prod-server.yml +++ b/.github/workflows/prod-server.yml @@ -82,7 +82,6 @@ jobs: -p $NEW_PORT:8080 \ --name $NEW_CONTAINER \ --network common \ - -e SPRING_PROFILES_ACTIVE=prod \ -e SPRING_DATASOURCE_URL="${{secrets.PROD_DB_URL}}" \ -e SPRING_DATASOURCE_USERNAME="${{secrets.PROD_DB_USERNAME}}" \ -e SPRING_DATASOURCE_PASSWORD="${{secrets.PROD_DB_PASSWORD}}" \ diff --git a/.github/workflows/test-server-cd.yml b/.github/workflows/test-server-cd.yml index 5908734f..4162dd05 100644 --- a/.github/workflows/test-server-cd.yml +++ b/.github/workflows/test-server-cd.yml @@ -59,7 +59,6 @@ jobs: -p $NEW_PORT:8080 \ --name $NEW_CONTAINER \ --network common \ - -e SPRING_PROFILES_ACTIVE=server \ -e SPRING_DATASOURCE_URL="${{secrets.TEST_DB_URL}}" \ -e SPRING_DATASOURCE_USERNAME="${{secrets.TEST_DB_USERNAME}}" \ -e SPRING_DATASOURCE_PASSWORD="${{secrets.TEST_DB_PASSWORD}}" \ diff --git a/.github/workflows/test-server-ci.yml b/.github/workflows/test-server-ci.yml index 28fcd620..14655ca7 100644 --- a/.github/workflows/test-server-ci.yml +++ b/.github/workflows/test-server-ci.yml @@ -68,7 +68,7 @@ jobs: - name: Generate application-secrets.yml run: | mkdir -p src/main/resources - echo "${{ secrets.APPLICATION_SECRET_YML_V2 }}" > src/main/resources/application-secrets.yml + echo "${{ secrets.APPLICATION_SECRET_YML }}" > src/main/resources/application-secrets.yml echo "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" >> src/main/resources/application-secrets.yml echo "spring.cloud.aws.region.static: ${{ secrets.AWS_REGION }}" >> src/main/resources/application-secrets.yml diff --git a/build.gradle b/build.gradle index 12a30205..5ce7ae5a 100644 --- a/build.gradle +++ b/build.gradle @@ -125,10 +125,6 @@ dependencies { // Awaitility (비동기 테스트 지원) testImplementation 'org.awaitility:awaitility:4.2.0' - - // retry (ai retry용) - implementation 'org.springframework.retry:spring-retry' - implementation 'org.springframework:spring-aspects' } dependencyManagement { diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index 2c926f0b..2e076056 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -41,7 +41,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler @Value("${front.redirect_domain}") private String redirect_domain; - private final String SITE_DOMAIN = "zoopzoop.kro.kr"; + @Value("${spring.profiles.active:dev}") + private String activeProfile; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, @@ -111,7 +112,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .maxAge(jwtProperties.getAccessTokenValidity() / 1000) // .domain() // 프론트엔드 & 백엔드 상위 도메인 // .secure(true) // https 필수 설정. - .domain(SITE_DOMAIN) + .domain(redirect_domain) .secure(true) .sameSite("None") .build(); @@ -120,7 +121,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo .httpOnly(true) .path("/") .maxAge(jwtProperties.getRefreshTokenValidity() / 1000) // RefreshToken 유효기간과 동일하게 - .domain(SITE_DOMAIN) + .domain(redirect_domain) .secure(true) .sameSite("None") .build(); diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java index 02ab979b..45e59df1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/controller/ApiV1DashboardController.java @@ -71,6 +71,6 @@ public ResponseEntity> getGraph( "ID: " + dashboardId + " 의 React-flow 데이터를 조회했습니다.", BodyForReactFlow.from(graph) )); - } + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java index fe682888..037b8882 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/BodyForReactFlow.java @@ -19,42 +19,27 @@ public record BodyForReactFlow( public record NodeDto( @JsonProperty("id") String nodeKey, @JsonProperty("type") String nodeType, - @JsonProperty("selected") Boolean selected, - @JsonProperty("dragging") Boolean dragging, - @JsonProperty("position") PositionDto positionDto, - @JsonProperty("measured") MeasurementsDto measurements, - @JsonProperty("data") DataDto data + Map data, + @JsonProperty("position") PositionDto positionDto ) { public record PositionDto( @JsonProperty("x") double x, @JsonProperty("y") double y - ) {} - - public record MeasurementsDto( - @JsonProperty("width") double width, - @JsonProperty("height") double height - ) {} - - public record DataDto( - @JsonProperty("content") String content, - @JsonProperty("createdAt") String createAt, // YYYY-MM-DD format - @JsonProperty("link") String sourceUrl, - @JsonProperty("title") String title, - @JsonProperty("user") WriterDto writer - ) {} - - public record WriterDto( - @JsonProperty("name") String name, - @JsonProperty("profileUrl") String profileImageUrl - ) {} - + ) {} } public record EdgeDto( @JsonProperty("id") String edgeKey, @JsonProperty("source") String sourceNodeKey, - @JsonProperty("target") String targetNodeKey + @JsonProperty("target") String targetNodeKey, + @JsonProperty("type") String edgeType, + @JsonProperty("animated") boolean isAnimated, + @JsonProperty("style") StyleDto styleDto ) { + public record StyleDto( + String stroke, + Double strokeWidth + ) {} } // DTO -> Entity, BodyForReactFlow를 Graph 엔티티로 변환 @@ -66,24 +51,10 @@ public Graph toEntity() { Node node = new Node(); node.setNodeKey(dto.nodeKey()); node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); - node.setSelected(dto.selected() != null ? dto.selected() : false); - node.setDragging(dto.dragging() != null ? dto.dragging() : false); - node.setPositionX(dto.positionDto().x()); - node.setPositionY(dto.positionDto().y()); - if (dto.measurements() != null) { - node.setWidth(dto.measurements().width()); - node.setHeight(dto.measurements().height()); - } - if (dto.data() != null) { - node.setData(Map.of( - "content", dto.data().content(), - "createdAt", dto.data().createAt(), - "sourceUrl", dto.data().sourceUrl(), - "title", dto.data().title(), - "writerName", dto.data().writer() != null ? dto.data().writer().name() : null, - "writerProfileImageUrl", dto.data().writer() != null ? dto.data().writer().profileImageUrl() : null - )); - } + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); + node.setGraph(graph); // 연관관계 설정 return node; }) .toList(); @@ -94,6 +65,12 @@ public Graph toEntity() { edge.setEdgeKey(dto.edgeKey()); edge.setSourceNodeKey(dto.sourceNodeKey()); edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } edge.setGraph(graph); // 연관관계 설정 return edge; }) @@ -111,20 +88,8 @@ public static BodyForReactFlow from(Graph graph) { .map(n -> new NodeDto( n.getNodeKey(), n.getNodeType().name().toUpperCase(), - n.isSelected(), - n.isDragging(), - new NodeDto.PositionDto(n.getPositionX(), n.getPositionY()), - new NodeDto.MeasurementsDto(n.getWidth(), n.getHeight()), - new NodeDto.DataDto( - n.getData().get("content"), - n.getData().get("createdAt"), - n.getData().get("sourceUrl"), - n.getData().get("title"), - new NodeDto.WriterDto( - n.getData().get("writerName"), - n.getData().get("writerProfileImageUrl") - ) - ) + n.getData(), + new NodeDto.PositionDto(n.getPositonX(), n.getPositonY()) )) .toList(); @@ -132,7 +97,10 @@ public static BodyForReactFlow from(Graph graph) { .map(e -> new EdgeDto( e.getEdgeKey(), e.getSourceNodeKey(), - e.getTargetNodeKey() + e.getTargetNodeKey(), + e.getEdgeType().name().toUpperCase(), + e.isAnimated(), + new EdgeDto.StyleDto(e.getStroke(), e.getStrokeWidth()) )) .toList(); @@ -145,22 +113,9 @@ public List toNodeEntities(Graph graph) { Node node = new Node(); node.setNodeKey(dto.nodeKey()); node.setNodeType(NodeType.valueOf(dto.nodeType().toUpperCase())); - node.setPositionX(dto.positionDto().x()); - node.setPositionY(dto.positionDto().y()); - if (dto.measurements() != null) { - node.setWidth(dto.measurements().width()); - node.setHeight(dto.measurements().height()); - } - if (dto.data() != null) { - node.setData(Map.of( - "content", dto.data().content(), - "createdAt", dto.data().createAt(), - "sourceUrl", dto.data().sourceUrl(), - "title", dto.data().title(), - "writerName", dto.data().writer() != null ? dto.data().writer().name() : null, - "writerProfileImageUrl", dto.data().writer() != null ? dto.data().writer().profileImageUrl() : null - )); - } + node.setData(dto.data()); + node.setPositonX(dto.positionDto().x()); + node.setPositonY(dto.positionDto().y()); node.setGraph(graph); // 연관관계 설정 return node; }) @@ -174,6 +129,12 @@ public List toEdgeEntities(Graph graph) { edge.setEdgeKey(dto.edgeKey()); edge.setSourceNodeKey(dto.sourceNodeKey()); edge.setTargetNodeKey(dto.targetNodeKey()); + edge.setEdgeType(EdgeType.valueOf(dto.edgeType().toUpperCase())); + edge.setAnimated(dto.isAnimated()); + if (dto.styleDto() != null) { + edge.setStroke(dto.styleDto().stroke()); + edge.setStrokeWidth(dto.styleDto().strokeWidth()); + } edge.setGraph(graph); // 연관관계 설정 return edge; }) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java deleted file mode 100644 index 4bfaa446..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ReqBodyForLiveblocksAuth.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.dashboard.dto; - -import java.util.List; -import java.util.Map; - -public record ReqBodyForLiveblocksAuth( - String userId, - UserInfo userInfo, - Map>permissions -) { - public record UserInfo( - String name, - String avatar - ) {} -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java deleted file mode 100644 index c7e29e22..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/dto/ResBodyForAuthToken.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.dashboard.dto; - -public record ResBodyForAuthToken( - String token -){ } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java index 91126fbb..65538cc1 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Edge.java @@ -22,4 +22,17 @@ public class Edge extends BaseEntity { @Column private String targetNodeKey; + + @Column + @Enumerated(EnumType.STRING) + private EdgeType edgeType; + + @Column + boolean isAnimated; + + @Column + private String stroke; + + @Column + private Double strokeWidth; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java index 00f3266d..64a3b3a8 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/entity/Node.java @@ -24,12 +24,6 @@ public class Node extends BaseEntity { @Enumerated(EnumType.STRING) private NodeType nodeType; - @Column - private boolean selected; - - @Column - private boolean dragging; - @ElementCollection @CollectionTable(name = "node_data", joinColumns = @JoinColumn(name = "node_id")) @MapKeyColumn(name = "data_key") @@ -37,14 +31,8 @@ public class Node extends BaseEntity { private Map data = new HashMap<>(); @Column - private double positionX; - - @Column - private double positionY; - - @Column - private double width; + private double positonX; @Column - private double height; + private double positonY; } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java index 46cabef7..4771b6f4 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/dashboard/service/DashboardService.java @@ -10,24 +10,16 @@ import org.springframework.transaction.annotation.Transactional; import org.tuna.zoopzoop.backend.domain.dashboard.dto.BodyForReactFlow; import org.tuna.zoopzoop.backend.domain.dashboard.dto.GraphUpdateMessage; -import org.tuna.zoopzoop.backend.domain.dashboard.dto.ReqBodyForLiveblocksAuth; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Dashboard; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Edge; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Graph; import org.tuna.zoopzoop.backend.domain.dashboard.entity.Node; import org.tuna.zoopzoop.backend.domain.dashboard.repository.DashboardRepository; import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership; -import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority; import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService; -import org.tuna.zoopzoop.backend.domain.space.space.entity.Space; -import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService; -import org.tuna.zoopzoop.backend.global.clients.liveblocks.LiveblocksClient; import java.nio.file.AccessDeniedException; -import java.util.Collections; import java.util.List; -import java.util.Map; @Service @RequiredArgsConstructor @@ -38,8 +30,7 @@ public class DashboardService { private final ObjectMapper objectMapper; private final SignatureService signatureService; private final RabbitTemplate rabbitTemplate; - private final SpaceService spaceService; - private final LiveblocksClient liveblocksClient; + // =========================== Graph 관련 메서드 =========================== @@ -143,59 +134,8 @@ public void queueGraphUpdate(Integer dashboardId, String requestBody, String sig rabbitTemplate.convertAndSend("zoopzoop.exchange", "graph.update.rk", message); } - // =========================== 기타 메서드 =========================== - - /** - * 특정 스페이스에 대한 Liveblocks 접속 토큰(JWT)을 발급합니다. - * @param spaceId 스페이스 ID - * @param member 토큰을 요청하는 멤버 - * @return 발급된 JWT 문자열 - * @throws AccessDeniedException 멤버가 해당 스페이스에 속해있지 않거나 권한이 없는 경우 - */ - @Transactional(readOnly = true) - public String getAuthTokenForSpace(Integer spaceId, Member member) throws AccessDeniedException { - Space space = spaceService.findById(spaceId); - - // 해당 스페이스에 멤버가 속해있는지, PENDING 상태는 아닌지 확인 - Membership membership = membershipService.findByMemberAndSpace(member, space); - if (membership.getAuthority().equals(Authority.PENDING)) { - throw new AccessDeniedException("스페이스에 가입된 멤버가 아닙니다."); - } - // Liveblocks Room ID 생성 - String roomId = "space_" + space.getId(); - - // Liveblocks에 전달할 사용자 정보 생성 - String userId = String.valueOf(member.getId()); - ReqBodyForLiveblocksAuth.UserInfo userInfo = new ReqBodyForLiveblocksAuth.UserInfo( - member.getName(), - member.getProfileImageUrl() - ); - - // Liveblocks 권한 설정 (내 서비스의 Authority -> Liveblocks 권한으로 변환) - List permissions; - switch (membership.getAuthority()) { - case ADMIN, READ_WRITE: - permissions = List.of("room:write"); - break; - case READ_ONLY: - permissions = Collections.emptyList(); // 빈 리스트는 읽기 전용을 의미 - break; - default: - // PENDING 등 다른 상태는 위에서 이미 필터링됨 - throw new AccessDeniedException("유효하지 않은 권한입니다."); - } - // Liveblocks Client에 전달할 요청 객체 생성 - ReqBodyForLiveblocksAuth authRequest = new ReqBodyForLiveblocksAuth( - userId, - userInfo, - Map.of(roomId, permissions) - ); - - // LiveblocksClient를 통해 토큰 발급 요청 - return liveblocksClient.getAuthToken(authRequest); - } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java index 936ec696..4e564c07 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/prompt/AiPrompt.java @@ -41,7 +41,7 @@ public class AiPrompt { - 제공된 태그와 중복 가능하다. - 필요하면 새로운 태그를 만들어도 된다. 4. 출력은 반드시 아래 JSON 형식으로 해라. Markdown 문법(```)은 쓰지 마라. - - 해당정보가 없을 시 summary하고 category는 빈 문자열, category는 null로 출력해줘라. + - 해당 정보가 없으면 null말고 무조건 빈 문자열로 출력해줘라. [출력 JSON 형식] { diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java index fa94e7aa..1ff5f460 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/ai/service/AiService.java @@ -1,19 +1,14 @@ package org.tuna.zoopzoop.backend.domain.datasource.ai.service; -import com.fasterxml.jackson.core.JsonParseException; -import com.fasterxml.jackson.core.JsonProcessingException; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AiExtractorDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.dto.AnalyzeContentDto; import org.tuna.zoopzoop.backend.domain.datasource.ai.prompt.AiPrompt; import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.TagRepository; -import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -21,12 +16,8 @@ @RequiredArgsConstructor public class AiService { private final ChatClient chatClient; + private final TagRepository tagRepository; - @Retryable( - maxAttempts = 3, - backoff = @Backoff(delay = 500), - retryFor = {JsonParseException.class, JsonProcessingException.class} - ) public AiExtractorDto extract(String rawHtml) { AiExtractorDto response = chatClient.prompt() .user(AiPrompt.EXTRACTION.formatted(rawHtml)) @@ -36,22 +27,6 @@ public AiExtractorDto extract(String rawHtml) { return response; } - @Recover - public AiExtractorDto extractRecover(Exception e, String rawHtml) { - return new AiExtractorDto( - "", - null, - "", - "", - "" - ); - } - - @Retryable( - maxAttempts = 3, - backoff = @Backoff(delay = 500), - retryFor = {JsonParseException.class, JsonProcessingException.class} - ) public AnalyzeContentDto analyzeContent(String content, List tagList) { // JSON 배열 문자열로 변환 String tags = tagList.stream() @@ -66,14 +41,4 @@ public AnalyzeContentDto analyzeContent(String content, List tagList) { return response; } - - @Recover - public AnalyzeContentDto analyzeContentRecover(Exception e, String content, List tagList) { - return new AnalyzeContentDto( - "", - null, - new ArrayList<>() - ); - } - } \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java new file mode 100644 index 00000000..a09b2bbd --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DataSourceController.java @@ -0,0 +1,198 @@ +package org.tuna.zoopzoop.backend.domain.datasource.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.datasource.dto.*; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.domain.datasource.service.PersonalDataSourceService; +import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; + +import java.io.IOException; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/archive") +@RequiredArgsConstructor +@Tag(name = "ApiV1DataSource(Personal)", description = "개인 아카이브 자료 API") +public class DataSourceController { + + private final PersonalDataSourceService personalApp; + + // ===== 등록 (개인만) ===== + // DataSourceController + + @Operation(summary = "자료 등록", description = "내 PersonalArchive 안에 자료를 등록합니다.") + @PostMapping("") + public ResponseEntity>> createDataSource( + @Valid @RequestBody reqBodyForCreateDataSource rq, + @AuthenticationPrincipal CustomUserDetails user + ) throws IOException { + int id = personalApp.create( + user.getMember().getId(), + rq.sourceUrl(), + rq.folderId(), + DataSourceService.CreateCmd.builder().build() + ); + return ResponseEntity.ok( + new RsData<>("200", "새로운 자료가 등록됐습니다.", Map.of("dataSourceId", id)) + ); + } + + + // ===== 단건 삭제 ===== + @Operation(summary = "자료 단건 삭제", description = "내 PersonalArchive 안에 자료를 단건 삭제합니다.") + @DeleteMapping("/{dataSourceId}") + public ResponseEntity>> delete( + @PathVariable Integer dataSourceId, + @AuthenticationPrincipal CustomUserDetails user + ) { + int deletedId = personalApp.deleteOne(user.getMember().getId(), dataSourceId); + return ResponseEntity.ok( + new RsData<>("200", deletedId + "번 자료가 삭제됐습니다.", Map.of("dataSourceId", deletedId)) + ); + } + + // ===== 다건 삭제 ===== + @Operation(summary = "자료 다건 삭제", description = "내 PersonalArchive 안에 자료를 다건 삭제합니다.") + @PostMapping("/delete") + public ResponseEntity> deleteMany( + @Valid @RequestBody reqBodyForDeleteMany rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + personalApp.deleteMany(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "복수개의 자료가 삭제됐습니다.", null)); + } + + // ===== 소프트 삭제/복원 ===== + @Operation(summary = "자료 다건 임시 삭제", description = "내 PersonalArchive 안에 자료들을 임시 삭제합니다.") + @PatchMapping("/soft-delete") + public ResponseEntity> softDelete(@RequestBody @Valid IdsRequest rq, + @AuthenticationPrincipal CustomUserDetails user) { + personalApp.softDelete(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "자료들이 임시 삭제됐습니다.", null)); + } + + @Operation(summary = "자료 다건 복원", description = "내 PersonalArchive 안에 자료들을 복원합니다.") + @PatchMapping("/restore") + public ResponseEntity> restore(@RequestBody @Valid IdsRequest rq, + @AuthenticationPrincipal CustomUserDetails user) { + personalApp.restore(user.getMember().getId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "자료들이 복구됐습니다.", null)); + } + + // ===== 이동 ===== + @Operation(summary = "자료 단건 이동", description = "내 PersonalArchive 안에 자료를 단건 이동합니다.") + @PatchMapping("/{dataSourceId}/move") + public ResponseEntity>> moveDataSource( + @PathVariable Integer dataSourceId, + @Valid @RequestBody reqBodyForMoveDataSource rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + var result = personalApp.moveOne(user.getMember().getId(), dataSourceId, rq.folderId()); + String msg = result.dataSourceId() + "번 자료가 " + result.folderId() + "번 폴더로 이동했습니다."; + return ResponseEntity.ok( + new RsData<>("200", msg, + Map.of("folderId", result.folderId(), "dataSourceId", result.dataSourceId())) + ); + } + + @Operation(summary = "자료 다건 이동", description = "내 PersonalArchive 안에 자료들을 다건 이동합니다.") + @PatchMapping("/move") + public ResponseEntity> moveMany( + @Valid @RequestBody reqBodyForMoveMany rq, + @AuthenticationPrincipal CustomUserDetails user + ) { + personalApp.moveMany(user.getMember().getId(), rq.folderId(), rq.dataSourceId()); + return ResponseEntity.ok(new RsData<>("200", "복수 개의 자료를 이동했습니다.", null)); + } + + // ===== 수정 ===== + @Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.") + @PatchMapping("/{dataSourceId}") + public ResponseEntity>> updateDataSource( + @PathVariable Integer dataSourceId, + @RequestBody reqBodyForUpdateDataSource body, + @AuthenticationPrincipal CustomUserDetails user + ) { + boolean anyPresent = + (body.title() != null && body.title().isPresent()) || + (body.summary() != null && body.summary().isPresent()) || + (body.sourceUrl() != null && body.sourceUrl().isPresent()) || + (body.imageUrl() != null && body.imageUrl().isPresent()) || + (body.source() != null && body.source().isPresent()) || + (body.tags() != null && body.tags().isPresent()) || + (body.category() != null && body.category().isPresent()); + if (!anyPresent) throw new IllegalArgumentException("변경할 값이 없습니다."); + + + var catNullable = body.category(); + + // category enum 변환 시도 + JsonNullable enumCat = null; + if (catNullable != null && catNullable.isPresent()) { + String raw = catNullable.get(); + try { + // 필요하면 대소문자 허용 로직 추가 + enumCat = JsonNullable.of(Category.valueOf(raw.toUpperCase())); + } catch (IllegalArgumentException ex) { + throw new IllegalArgumentException("유효하지 않은 카테고리입니다: " + raw); + } + } + + int updatedId = personalApp.update( + user.getMember().getId(), + dataSourceId, + DataSourceService.UpdateCmd.builder() + .title(body.title()).summary(body.summary()).sourceUrl(body.sourceUrl()) + .imageUrl(body.imageUrl()).source(body.source()) + .tags(body.tags()).category(enumCat) + .build() + ); + + return ResponseEntity.ok( + new RsData<>("200", updatedId + "번 자료가 수정됐습니다.", Map.of("dataSourceId", updatedId)) + ); + } + + // ===== 검색 ===== + @Operation(summary = "자료 검색", description = "내 PersonalArchive 안에 자료들을 검색합니다.") + @GetMapping("") + public ResponseEntity>> search( + @RequestParam(required = false) String title, + @RequestParam(required = false) String summary, + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer folderId, + @RequestParam(required = false) String folderName, + @RequestParam(required = false, defaultValue = "true") Boolean isActive, + @PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal CustomUserDetails user + ) { + var cond = DataSourceSearchCondition.builder() + .title(title).summary(summary).category(category).folderId(folderId) + .folderName(folderName).isActive(isActive).keyword(keyword).build(); + + Page page = personalApp.search(user.getMember().getId(), cond, pageable); + String sorted = pageable.getSort().toString().replace(": ", ","); + + var pageInfo = new PageInfo( + page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages(), + page.isFirst(), page.isLast(), sorted + ); + var body = new SearchResponse<>(page.getContent(), pageInfo); + + return ResponseEntity.ok(new RsData<>("200", "복수개의 자료가 조회됐습니다.", body)); + } +} \ No newline at end of file diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java deleted file mode 100644 index 9443e2f8..00000000 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/controller/DatasourceController.java +++ /dev/null @@ -1,274 +0,0 @@ -package org.tuna.zoopzoop.backend.domain.datasource.controller; - -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.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; -import org.tuna.zoopzoop.backend.domain.datasource.dto.*; -import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; -import org.tuna.zoopzoop.backend.domain.member.entity.Member; -import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; - -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.Map; - -@RestController -@RequestMapping("/api/v1/archive") -@RequiredArgsConstructor -@Tag(name = "ApiV1DataSource", description = "개인 아카이브의 파일 CRUD") -public class DatasourceController { - - private final DataSourceService dataSourceService; - - /** - * 자료 등록 - * sourceUrl 등록할 자료 url - * folderId 등록될 폴더 위치(null 이면 default) - */ - @Operation(summary = "자료 등록", description = "내 PersonalArchive 안에 자료를 등록합니다.") - @PostMapping("") - public ResponseEntity createDataSource( - @Valid @RequestBody reqBodyForCreateDataSource rq, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - // 로그인된 멤버 Id 사용 - Member member = userDetails.getMember(); - Integer currentMemberId = member.getId(); - - int rs = dataSourceService.createDataSource(currentMemberId, rq.sourceUrl(), rq.folderId()); - return ResponseEntity.ok() - .body( - new ApiResponse<>(200, "새로운 자료가 등록됐습니다.", rs) - ); - } - - /** - * 자료 단건 완전 삭제 - */ - @Operation(summary = "자료 단건 삭제", description = "내 PersonalArchive 안에 자료를 단건 삭제합니다.") - @DeleteMapping("/{dataSourceId}") - public ResponseEntity> delete( - @PathVariable Integer dataSourceId, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - Member member = userDetails.getMember(); - int deletedId = dataSourceService.deleteById(member.getId(), dataSourceId); - return ResponseEntity.ok( - Map.of( - "status", 200, - "msg", deletedId + "번 자료가 삭제됐습니다.", - "data", Map.of("dataSourceId", deletedId) - ) - ); - } - - /** - * 자료 다건 완전 삭제 - */ - @Operation(summary = "자료 다건 삭제", description = "내 PersonalArchive 안에 자료를 다건 삭제합니다.") - @PostMapping("/delete") - public ResponseEntity> deleteMany( - @Valid @RequestBody reqBodyForDeleteMany body, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - Member member = userDetails.getMember(); - dataSourceService.deleteMany(member.getId(), body.dataSourceId()); - - Map res = new java.util.LinkedHashMap<>(); - res.put("status", 200); - res.put("msg", "복수개의 자료가 삭제됐습니다."); - res.put("data", null); - - return ResponseEntity.ok(res); - } - - /** - * 자료 다건 소프트 삭제 - */ - @Operation(summary = "자료 다건 임시 삭제", description = "내 PersonalArchive 안에 자료들을 임시 삭제합니다.") - @PatchMapping("/soft-delete") - public ResponseEntity softDelete( - @RequestBody @Valid IdsRequest req, - @AuthenticationPrincipal CustomUserDetails user) { - - int cnt = dataSourceService.softDelete(user.getMember().getId(), req.ids()); - Map res = new LinkedHashMap<>(); - res.put("status", 200); - res.put("msg", "자료들이 임시 삭제됐습니다."); - res.put("data", null); - return ResponseEntity.ok(res); - } - /** - * 자료 다건 복원 - */ - @Operation(summary = "자료 다건 복원", description = "내 PersonalArchive 안에 자료들을 복원합니다.") - @PatchMapping("/restore") - public ResponseEntity restore( - @RequestBody @Valid IdsRequest req, - @AuthenticationPrincipal CustomUserDetails user) { - - int cnt = dataSourceService.restore(user.getMember().getId(), req.ids()); - Map res = new LinkedHashMap<>(); - res.put("status", 200); - res.put("msg", "자료들이 복구됐습니다."); - res.put("data", null); - return ResponseEntity.ok(res); - } - /** - * 자료 단건 이동 - * folderId=null 이면 default 폴더 - */ - @Operation(summary = "자료 단건 이동", description = "내 PersonalArchive 안에 자료를 단건 이동합니다.") - @PatchMapping("/{dataSourceId}/move") - public ResponseEntity moveDataSource( - @PathVariable Integer dataSourceId, - @Valid @RequestBody reqBodyForMoveDataSource rq, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - Member member = userDetails.getMember(); - Integer currentMemberId = member.getId(); - - DataSourceService.MoveResult result = - dataSourceService.moveDataSource(currentMemberId, dataSourceId, rq.folderId()); - resBodyForMoveDataSource body = - new resBodyForMoveDataSource(result.datasourceId(), result.folderId()); - String msg = body.dataSourceId() + "번 자료가 " + body.folderId() + "번 폴더로 이동했습니다."; - - return ResponseEntity.ok( - Map.of( - "status", 200, - "msg", msg, - "data", java.util.Map.of( - "folderId", body.folderId(), - "dataSourceId", body.dataSourceId() - ) - ) - ); - } - - /** - * 자료 다건 이동 - */ - @Operation(summary = "자료 다건 이동", description = "내 PersonalArchive 안에 자료들를 다건 이동합니다..") - @PatchMapping("/move") - public ResponseEntity moveMany( - @Valid @RequestBody reqBodyForMoveMany rq, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - Member member = userDetails.getMember(); - Integer currentMemberId = member.getId(); - - dataSourceService.moveDataSources(currentMemberId, rq.folderId(), rq.dataSourceId()); - - Map res = new HashMap<>(); - res.put("status", 200); - res.put("msg", "복수 개의 자료를 이동했습니다."); - res.put("data", null); - - return ResponseEntity.ok(res); - } - - /** - * 파일 수정 - * - 전달된 필드만 반영 (present) - * - 명시적 null이면 DB에 null 저장 - * - 미전달(not present)이면 변경 없음 - */ - @Operation(summary = "자료 수정", description = "내 PersonalArchive 안에 자료를 수정합니다.") - @PatchMapping("/{dataSourceId}") - public ResponseEntity updateDataSource( - @PathVariable Integer dataSourceId, - @RequestBody reqBodyForUpdateDataSource body, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - boolean anyPresent = - body.title().isPresent() || - body.summary().isPresent() || - body.sourceUrl().isPresent() || - body.imageUrl().isPresent() || - body.source().isPresent() || - body.tags().isPresent() || - body.category().isPresent(); - - if (!anyPresent) { - throw new IllegalArgumentException( - "변경할 값이 없습니다. title, summary, sourceUrl, imageUrl, source, tags, category 중 하나 이상을 전달하세요." - ); - } - - Integer updatedId = dataSourceService.updateDataSource( - userDetails.getMember().getId(), - dataSourceId, - DataSourceService.UpdateCommand.builder() - .title(body.title()) - .summary(body.summary()) - .sourceUrl(body.sourceUrl()) - .imageUrl(body.imageUrl()) - .source(body.source()) - .tags(body.tags()) - .category(body.category()) - .build() - ); - - String msg = updatedId + "번 자료가 수정됐습니다."; - return ResponseEntity.ok(new ApiResponse<>(200, msg, new resBodyForUpdateDataSource(updatedId))); - } - - /** - * 자료 검색 - */ - @Operation(summary = "자료 검색", description = "내 PersonalArchive 안에 자료들을 검색합니다.") - @GetMapping("") - public ResponseEntity search( - @RequestParam(required = false) String title, - @RequestParam(required = false) String summary, - @RequestParam(required = false) String category, - @RequestParam(required = false) String keyword, - @RequestParam(required = false) Integer folderId, - @RequestParam(required = false) String folderName, - @RequestParam(required = false, defaultValue = "true") Boolean isActive, - @PageableDefault(size = 8, sort = "createdAt", direction = Sort.Direction.DESC) - Pageable pageable, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - Integer memberId = userDetails.getMember().getId(); - - DataSourceSearchCondition cond = DataSourceSearchCondition.builder() - .title(title) - .summary(summary) - .category(category) - .folderId(folderId) - .folderName(folderName) - .isActive(isActive) - .keyword(keyword) - .build(); - - Page page = dataSourceService.search(memberId, cond, pageable); - String sorted = pageable.getSort().toString().replace(": ", ","); - - Map res = new LinkedHashMap<>(); - res.put("status", 200); - res.put("msg", "복수개의 자료가 조회됐습니다."); - res.put("data", page.getContent()); - res.put("pageInfo", Map.of( - "page", page.getNumber(), - "size", page.getSize(), - "totalElements", page.getTotalElements(), - "totalPages", page.getTotalPages(), - "first", page.isFirst(), - "last", page.isLast(), - "sorted", sorted - )); - return ResponseEntity.ok(res); - } - - record ApiResponse(int status, String msg, T data) {} -} diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java index e8eb831a..6ab8f443 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/Crawler.java @@ -3,10 +3,11 @@ import org.jsoup.nodes.Document; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import java.io.IOException; import java.time.LocalDate; public interface Crawler { boolean supports(String domain); - CrawlerResult extract(Document doc); + CrawlerResult extract(Document doc) throws IOException; LocalDate transLocalDate(String rawDate); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java index 4b9a472f..5a511c09 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/CrawlerManagerService.java @@ -1,10 +1,12 @@ package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; import lombok.RequiredArgsConstructor; +import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.springframework.stereotype.Service; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; +import java.io.IOException; import java.util.List; @Service @@ -12,13 +14,17 @@ public class CrawlerManagerService { private final List crawlers; - public CrawlerResult extractContent(String url, Document doc) { + public CrawlerResult extractContent(String url) throws IOException { + Document doc = Jsoup.connect(url) + .userAgent("Mozilla/5.0") + .timeout(10000) + .get(); + for (Crawler crawler : crawlers) { if (crawler.supports(url)) { return crawler.extract(doc); } } - return null; } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java index 95d9cf67..c36ede86 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/GenericCrawler.java @@ -19,30 +19,11 @@ public boolean supports(String url) { @Override public CrawlerResult extract(Document doc) { - // img 태그 - doc.select("img[src]").forEach(el -> - el.attr("src", el.absUrl("src")) - ); - - // meta 태그 (Open Graph, Twitter Card 등) - doc.select("meta[content]").forEach(meta -> { - String absUrl = meta.absUrl("content"); - if (!absUrl.isEmpty() && !absUrl.equals(meta.attr("content"))) { - meta.attr("content", absUrl); - } - }); + // 불필요한 태그 제거 + doc.select("script, style, noscript, meta, link").remove(); // 본문만 가져오기 (HTML) - String cleanHtml = doc.body().html() - .replaceAll("]*>.*?", "") - .replaceAll("]*>.*?", "") - // 주석 제거 - .replaceAll("", "") - // 연속된 공백 제거 - .replaceAll("\\s+", " ") - // 불필요한 속성 제거 - .replaceAll("(class|id|style|onclick|onload)=\"[^\"]*\"", "") - .trim(); + String cleanHtml = doc.body().html(); return new CrawlerResult<>( CrawlerResult.CrawlerType.UNSPECIFIC, diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java index 1aa10c13..640686a7 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/datasource/crawler/service/NaverBlogCrawler.java @@ -1,6 +1,5 @@ package org.tuna.zoopzoop.backend.domain.datasource.crawler.service; -import org.jsoup.Connection; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; @@ -10,14 +9,11 @@ import org.springframework.stereotype.Component; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.CrawlerResult; import org.tuna.zoopzoop.backend.domain.datasource.crawler.dto.SpecificSiteDto; -import org.tuna.zoopzoop.backend.domain.datasource.exception.ServiceException; +import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Component @Order(Ordered.HIGHEST_PRECEDENCE) @@ -32,40 +28,28 @@ public boolean supports(String domain) { } @Override - public CrawlerResult extract(Document doc) { + public CrawlerResult extract(Document doc) throws IOException { /* 블로그 본문은