Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.UUID;

Expand Down Expand Up @@ -72,8 +73,10 @@ public ApiResponse<ContentListResponse> search(
@GetMapping("/{contentId}")
public ApiResponse<ContentDetailResponse> getDetail(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "콘텐츠 ID (UUID)", required = true) @PathVariable UUID contentId) {
return ApiResponse.ok(contentService.getDetail(userId, contentId));
@Parameter(description = "콘텐츠 ID (UUID)", required = true) @PathVariable UUID contentId,
HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
return ApiResponse.ok(contentService.getDetail(userId, contentId, userAgent));
}

@Operation(summary = "원문 확인(학습 기록)", description = "외부 원문 링크를 연 시점에 호출합니다. 학습 히스토리(content_opened)가 기록됩니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.devpick.domain.content.entity;

import com.devpick.global.entity.BaseCreatedEntity;
import jakarta.persistence.*;
import lombok.*;

import java.util.UUID;

@Entity
@Table(name = "content_view_logs",
indexes = {
@Index(name = "idx_content_view_logs_content_period", columnList = "content_id, created_at"),
@Index(name = "idx_content_view_logs_period", columnList = "created_at")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class ContentViewLog extends BaseCreatedEntity {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id", nullable = false)
private Content content;

@Column(name = "user_id", columnDefinition = "uuid")
private UUID userId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.devpick.domain.content.repository;

import com.devpick.domain.content.entity.ContentViewLog;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface ContentViewLogRepository extends JpaRepository<ContentViewLog, UUID> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -33,6 +34,7 @@

@Service
@RequiredArgsConstructor
@Slf4j
public class ContentService {

private static final String FEED_SUMMARY_LEVEL = "JUNIOR";
Expand All @@ -45,6 +47,7 @@ public class ContentService {
private final UserTagRepository userTagRepository;
private final PointService pointService;
private final AiSummaryService aiSummaryService;
private final ContentViewLogService contentViewLogService;

@Transactional(readOnly = true)
public ContentListResponse getFeed(UUID userId, Pageable pageable) {
Expand Down Expand Up @@ -86,7 +89,7 @@ public ContentListResponse getFeed(UUID userId, Pageable pageable) {
}

@Transactional
public ContentDetailResponse getDetail(UUID userId, UUID contentId) {
public ContentDetailResponse getDetail(UUID userId, UUID contentId, String userAgent) {
Content content = contentRepository.findByIdAndIsAvailableTrue(contentId)
.orElseThrow(() -> new DevpickException(ErrorCode.CONTENT_NOT_FOUND));

Expand All @@ -96,6 +99,12 @@ public ContentDetailResponse getDetail(UUID userId, UUID contentId) {
boolean isScrapped = scrapRepository.existsByUser_IdAndContent_Id(userId, contentId);
boolean isLiked = likeRepository.existsByUser_IdAndContent_Id(userId, contentId);

try {
contentViewLogService.record(content, userId, userAgent);
} catch (Exception e) {
log.warn("뷰 로그 기록 실패 (무시): contentId={}", contentId);
}

return ContentDetailResponse.of(content, isScrapped, isLiked);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.devpick.domain.content.service;

import com.devpick.domain.content.entity.Content;
import com.devpick.domain.content.entity.ContentViewLog;
import com.devpick.domain.content.repository.ContentViewLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.UUID;
import java.util.regex.Pattern;

@Service
@RequiredArgsConstructor
@Slf4j
public class ContentViewLogService {

private static final Pattern BOT_PATTERN = Pattern.compile("(?i)bot|crawler|spider|scraper");

private final ContentViewLogRepository contentViewLogRepository;

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(Content content, UUID userId, String userAgent) {

Check warning on line 25 in src/main/java/com/devpick/domain/content/service/ContentViewLogService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this method to not match a restricted identifier.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ28wJynpz1wVSI85yVv&open=AZ28wJynpz1wVSI85yVv&pullRequest=128
if (isBot(userAgent)) {
log.debug("봇 UA 감지, skip: ua={}", userAgent);
return;
}
contentViewLogRepository.save(
ContentViewLog.builder()
.content(content)
.userId(userId)
.build()
);
}

private boolean isBot(String userAgent) {
return userAgent != null && BOT_PATTERN.matcher(userAgent).find();
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/devpick/domain/trend/entity/TrendSnapshot.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.devpick.domain.trend.entity;

import com.devpick.global.entity.BaseCreatedEntity;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Table(name = "trend_snapshots",
uniqueConstraints = @UniqueConstraint(
name = "uq_trend_snapshots_unit_scope_period",
columnNames = {"unit", "scope", "period_start"}
),
indexes = @Index(name = "idx_trend_snapshots_lookup",
columnList = "unit, scope, period_start")
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@AllArgsConstructor
public class TrendSnapshot extends BaseCreatedEntity {

@Column(length = 10, nullable = false)
private String unit;

@Column(length = 20, nullable = false)
@Builder.Default
private String scope = "global";

@Column(name = "period_start", nullable = false)
private LocalDate periodStart;

@Column(name = "period_end", nullable = false)
private LocalDate periodEnd;

@Column(columnDefinition = "jsonb", nullable = false)
private String payload;

@Column(name = "generated_at", nullable = false)
private LocalDateTime generatedAt;

@Column(name = "expires_at")
private LocalDateTime expiresAt;
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ void getDetail_success() throws Exception {
null, false, null, Instant.now(),
List.of("Spring"), false, false,
null, null, null, null, null, null, null, null);
given(contentService.getDetail(userId, contentId)).willReturn(response);
given(contentService.getDetail(eq(userId), eq(contentId), any())).willReturn(response);

mockMvc.perform(get("/contents/" + contentId))
.andExpect(status().isOk())
Expand All @@ -129,7 +129,7 @@ void getDetail_success() throws Exception {
@DisplayName("GET /contents/{contentId} - 콘텐츠 없으면 404 반환")
void getDetail_notFound_returns404() throws Exception {
UUID contentId = UUID.randomUUID();
given(contentService.getDetail(userId, contentId))
given(contentService.getDetail(eq(userId), eq(contentId), any()))
.willThrow(new DevpickException(ErrorCode.CONTENT_NOT_FOUND));

mockMvc.perform(get("/contents/" + contentId))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ class ContentServiceTest {
private com.devpick.domain.point.service.PointService pointService;
@Mock
private AiSummaryService aiSummaryService;
@Mock
private ContentViewLogService contentViewLogService;

private UUID userId;
private UUID contentId;
Expand Down Expand Up @@ -196,7 +198,7 @@ void getDetail_success_doesNotSaveContentOpenedHistory() {
given(scrapRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);
given(likeRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);

ContentDetailResponse response = contentService.getDetail(userId, contentId);
ContentDetailResponse response = contentService.getDetail(userId, contentId, "Mozilla/5.0");

assertThat(response.title()).isEqualTo("Spring Boot 가이드");
assertThat(response.sourceName()).isEqualTo("Velog");
Expand All @@ -222,7 +224,7 @@ void recordContentOriginalOpened_success_savesHistory() {
void getDetail_contentNotFound_throwsException() {
given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.empty());

assertThatThrownBy(() -> contentService.getDetail(userId, contentId))
assertThatThrownBy(() -> contentService.getDetail(userId, contentId, "Mozilla/5.0"))
.isInstanceOf(DevpickException.class)
.satisfies(e -> assertThat(((DevpickException) e).getErrorCode())
.isEqualTo(ErrorCode.CONTENT_NOT_FOUND));
Expand Down Expand Up @@ -322,7 +324,7 @@ void getDetail_userNotFound_throwsException() {
given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content));
given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.empty());

assertThatThrownBy(() -> contentService.getDetail(userId, contentId))
assertThatThrownBy(() -> contentService.getDetail(userId, contentId, "Mozilla/5.0"))
.isInstanceOf(DevpickException.class)
.satisfies(e -> assertThat(((DevpickException) e).getErrorCode())
.isEqualTo(ErrorCode.USER_NOT_FOUND));
Expand Down Expand Up @@ -373,7 +375,7 @@ void getDetail_withScrapAndLike_flagsTrue() {
given(scrapRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(true);
given(likeRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(true);

ContentDetailResponse response = contentService.getDetail(userId, contentId);
ContentDetailResponse response = contentService.getDetail(userId, contentId, "Mozilla/5.0");

assertThat(response.isScrapped()).isTrue();
assertThat(response.isLiked()).isTrue();
Expand Down Expand Up @@ -500,10 +502,38 @@ void getDetail_isOriginalVisibleFalse_originalContentIsNull() {
given(scrapRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);
given(likeRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);

ContentDetailResponse response = contentService.getDetail(userId, contentId);
ContentDetailResponse response = contentService.getDetail(userId, contentId, "Mozilla/5.0");

// isOriginalVisible 기본값 false → originalContent는 null이어야 함
assertThat(response.isOriginalVisible()).isFalse();
assertThat(response.originalContent()).isNull();
}

@Test
@DisplayName("getDetail — 정상 요청 시 contentViewLogService.record 호출")
void getDetail_success_recordsViewLog() {
given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content));
given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user));
given(scrapRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);
given(likeRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);

contentService.getDetail(userId, contentId, "Mozilla/5.0");

verify(contentViewLogService).record(any(Content.class), eq(userId), eq("Mozilla/5.0"));
}

@Test
@DisplayName("getDetail — 뷰 로그 기록 실패해도 ContentDetailResponse 정상 반환")
void getDetail_viewLogThrows_stillReturnsDetail() {
given(contentRepository.findByIdAndIsAvailableTrue(contentId)).willReturn(Optional.of(content));
given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user));
given(scrapRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);
given(likeRepository.existsByUser_IdAndContent_Id(userId, contentId)).willReturn(false);
org.mockito.Mockito.doThrow(new RuntimeException("DB 오류"))
.when(contentViewLogService).record(any(), any(), any());

ContentDetailResponse response = contentService.getDetail(userId, contentId, "Mozilla/5.0");

assertThat(response.title()).isEqualTo("Spring Boot 가이드");
}
}
Loading
Loading