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 @@ -107,6 +107,9 @@ public void deleteAnswer(UUID userId, UUID postId, UUID answerId) {
throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_ANSWER_ACTION);
}

// 포인트 환불: ANSWER_WRITE (항상), ANSWER_ADOPTED (채택된 경우)
pointService.refundAnswerPoints(answer.getUser(), Boolean.TRUE.equals(answer.getIsAdopted()));

// 자식 레코드 순서대로 삭제 (FK 제약조건 준수)
commentRepository.deleteByAnswerId(answerId);
answerLikeRepository.deleteByAnswerId(answerId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public CommentResponse createComment(UUID userId, UUID postId, UUID answerId,
.actionType("comment_created")
.post(answer.getPost())
.answer(answer)
.comment(savedComment)
.build());
return CommentResponse.of(savedComment);
}
Expand Down Expand Up @@ -92,6 +93,7 @@ public void deleteComment(UUID userId, UUID postId, UUID answerId, UUID commentI
throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_COMMENT_ACTION);
}

historyRepository.deleteByCommentId(commentId);
commentRepository.delete(comment);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ public void deletePost(UUID userId, UUID postId) {
throw new DevpickException(ErrorCode.COMMUNITY_UNAUTHORIZED_POST_ACTION);
}

// 포인트 환불: QUESTION_WRITE
pointService.refundLatestByAction(post.getUser(), PointAction.QUESTION_WRITE);

// 자식 레코드 순서대로 삭제 (FK 제약조건 준수)
commentRepository.deleteByPostId(postId);
answerLikeRepository.deleteByPostId(postId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.devpick.domain.content.dto.ContentDetailResponse;
import com.devpick.domain.content.dto.ContentListResponse;
import com.devpick.domain.content.dto.SummaryViewedRequest;
import com.devpick.domain.content.service.AiSummaryService;
import com.devpick.domain.content.service.ContentService;
import com.devpick.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -17,6 +19,7 @@
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;
Expand All @@ -32,6 +35,7 @@
public class ContentController {

private final ContentService contentService;
private final AiSummaryService aiSummaryService;

@Operation(summary = "개인화 피드 조회", description = "사용자의 기술 태그와 레벨에 맞는 개인화된 콘텐츠 목록을 반환합니다.")
@ApiResponses({
Expand Down Expand Up @@ -76,6 +80,21 @@
return ApiResponse.ok(contentService.getDetail(userId, contentId));
}

@Operation(summary = "AI 요약 조회 히스토리 기록", description = "AI 요약 섹션 최초 진입 시 1회 호출합니다. 히스토리(ai_summary_viewed)가 기록됩니다.")
@ApiResponses({

Check warning on line 84 in src/main/java/com/devpick/domain/content/controller/ContentController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2FwLAg7pjpPm43XSVB&open=AZ2FwLAg7pjpPm43XSVB&pullRequest=126
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "기록 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "콘텐츠를 찾을 수 없음")
})
@PostMapping("/{contentId}/summary/viewed")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void recordSummaryViewed(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "콘텐츠 ID (UUID)", required = true) @PathVariable UUID contentId,
@RequestBody SummaryViewedRequest request) {
aiSummaryService.recordSummaryViewed(userId, contentId, request.level());
}

@Operation(summary = "원문 확인(학습 기록)", description = "외부 원문 링크를 연 시점에 호출합니다. 학습 히스토리(content_opened)가 기록됩니다.")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "기록 성공"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.devpick.domain.content.dto;

public record SummaryViewedRequest(String level) {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,8 @@
import com.devpick.domain.content.dto.AiSummaryResult;
import com.devpick.domain.content.repository.AiSummaryRepository;
import com.devpick.domain.content.repository.ContentRepository;
import com.devpick.domain.point.entity.PointAction;
import com.devpick.domain.point.service.PointService;
import com.devpick.domain.report.entity.History;
import com.devpick.domain.report.repository.HistoryRepository;
import com.devpick.domain.user.entity.User;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
Expand Down Expand Up @@ -42,7 +39,6 @@ public class AiSummaryService {
private final HistoryRepository historyRepository;
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final PointService pointService;
private final ContentTagService contentTagService;

@Transactional
Expand All @@ -57,7 +53,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) {
String redisKey = buildRedisKey(contentId, aiLevel);
AiSummaryResponse cached = getFromRedis(redisKey);
if (cached != null && cached.expiresAt() != null && cached.expiresAt().isAfter(Instant.now())) {
recordHistory(userId, contentId);
return cached;
}

Expand All @@ -67,7 +62,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) {
&& docOpt.get().getExpiresAt().isAfter(LocalDateTime.now())) {
AiSummaryResponse response = AiSummaryResponse.of(docOpt.get());
saveToRedis(redisKey, response);
recordHistory(userId, contentId);
return response;
}

Expand All @@ -82,7 +76,6 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) {

AiSummaryResponse response = AiSummaryResponse.of(doc);
saveToRedis(redisKey, response);
recordHistory(userId, contentId);
return response;
}

Expand Down Expand Up @@ -163,16 +156,16 @@ public Optional<String> findCachedCoreSummary(UUID contentId, String level) {
}
}

private void recordHistory(UUID userId, UUID contentId) {
public void recordSummaryViewed(UUID userId, UUID contentId, String level) {
userRepository.findByIdAndIsActiveTrue(userId).ifPresent(user ->
contentRepository.findByIdAndIsAvailableTrue(contentId).ifPresent(content -> {
historyRepository.save(History.builder()
.user(user)
.actionType("ai_summary_viewed")
.content(content)
.build());
pointService.earn(user, PointAction.AI_SUMMARY_VIEW);
})
contentRepository.findByIdAndIsAvailableTrue(contentId).ifPresent(content ->
historyRepository.save(History.builder()
.user(user)
.actionType("ai_summary_viewed")
.content(content)
.level(level)
.build())
)
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ public void addScrap(UUID userId, UUID contentId) {
public void removeScrap(UUID userId, UUID contentId) {
Scrap scrap = scrapRepository.findByUser_IdAndContent_Id(userId, contentId)
.orElseThrow(() -> new DevpickException(ErrorCode.CONTENT_NOT_SCRAPED));
User user = userRepository.findByIdAndIsActiveTrue(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
scrapRepository.delete(scrap);
historyRepository.deleteByUserIdAndContentIdAndActionType(userId, contentId, "scrapped");
pointService.refund(user, PointAction.CONTENT_SCRAP, contentId);
}

@Transactional
Expand Down Expand Up @@ -166,7 +170,11 @@ public void addLike(UUID userId, UUID contentId) {
public void removeLike(UUID userId, UUID contentId) {
Like like = likeRepository.findByUser_IdAndContent_Id(userId, contentId)
.orElseThrow(() -> new DevpickException(ErrorCode.CONTENT_NOT_LIKED));
User user = userRepository.findByIdAndIsActiveTrue(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));
likeRepository.delete(like);
historyRepository.deleteByUserIdAndContentIdAndActionType(userId, contentId, "content_liked");
pointService.refund(user, PointAction.CONTENT_LIKE, contentId);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface PointLogRepository extends JpaRepository<PointLog, UUID> {
Expand All @@ -36,4 +38,19 @@ int sumPointsByUserIdAndEarnedAtBetween(
"WHERE pl.user.id = :userId AND pl.action = 'DAILY_LOGIN' " +
"ORDER BY pl.earnedAt DESC")
List<PointLog> findDailyLoginsByUserIdOrderByEarnedAtDesc(@Param("userId") UUID userId);

@Modifying
@Query("DELETE FROM PointLog pl WHERE pl.user.id = :userId AND pl.action = :action AND pl.referenceId = :referenceId")
void deleteByUser_IdAndActionAndReferenceId(
@Param("userId") UUID userId,
@Param("action") PointAction action,
@Param("referenceId") UUID referenceId);

@Query("SELECT COALESCE(SUM(pl.points), 0) FROM PointLog pl WHERE pl.user.id = :userId AND pl.action = :action AND pl.referenceId = :referenceId")
int sumPointsByUser_IdAndActionAndReferenceId(
@Param("userId") UUID userId,
@Param("action") PointAction action,
@Param("referenceId") UUID referenceId);

Optional<PointLog> findTopByUser_IdAndActionOrderByEarnedAtDesc(UUID userId, PointAction action);
}
37 changes: 37 additions & 0 deletions src/main/java/com/devpick/domain/point/service/PointService.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,43 @@

// ── 중복 적립 방지 ──────────────────────────────────────────────────

/**
* referenceId 기준으로 적립된 포인트를 환불한다 (스크랩/좋아요 취소 시).
*/
@Transactional
public void refund(User user, PointAction action, UUID referenceId) {
int refundPoints = pointLogRepository.sumPointsByUser_IdAndActionAndReferenceId(
user.getId(), action, referenceId);
if (refundPoints <= 0) return;
pointLogRepository.deleteByUser_IdAndActionAndReferenceId(user.getId(), action, referenceId);
user.subtractPoints(refundPoints);
userRepository.save(user);
}

/**
* 가장 최근 적립 로그 1건을 환불한다 (답변/게시글 삭제 시).
*/
@Transactional
public void refundLatestByAction(User user, PointAction action) {
pointLogRepository.findTopByUser_IdAndActionOrderByEarnedAtDesc(user.getId(), action)
.ifPresent(log -> {
pointLogRepository.delete(log);
user.subtractPoints(log.getPoints());
userRepository.save(user);
});
}

/**
* 답변 삭제 시 포인트 환불: ANSWER_WRITE (항상), ANSWER_ADOPTED (채택된 경우).
*/
@Transactional
public void refundAnswerPoints(User user, boolean wasAdopted) {
refundLatestByAction(user, PointAction.ANSWER_WRITE);

Check failure on line 139 in src/main/java/com/devpick/domain/point/service/PointService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Call transactional methods via an injected dependency instead of directly via 'this'.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2FwLEH7pjpPm43XSVC&open=AZ2FwLEH7pjpPm43XSVC&pullRequest=126
if (wasAdopted) {
refundLatestByAction(user, PointAction.ANSWER_ADOPTED);

Check failure on line 141 in src/main/java/com/devpick/domain/point/service/PointService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Call transactional methods via an injected dependency instead of directly via 'this'.

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2FwLEH7pjpPm43XSVD&open=AZ2FwLEH7pjpPm43XSVD&pullRequest=126
}
}

private boolean isDuplicate(UUID userId, PointAction action, UUID referenceId) {
return switch (action) {
case CONTENT_SCRAP, CONTENT_LIKE, AI_QUIZ_PASS ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ public record HistoryItemResponse(
ContentInfo content,
PostInfo post,
AnswerInfo answer,
CommentInfo comment,
Instant createdAt
) {
public record ContentInfo(UUID id, String title, String preview) {}
public record PostInfo(UUID id, String title) {}
public record AnswerInfo(UUID id) {}
public record AnswerInfo(UUID id, String preview) {}
public record CommentInfo(UUID id, String preview) {}

public static HistoryItemResponse of(History history) {
ContentInfo contentInfo = history.getContent() != null
Expand All @@ -35,11 +37,18 @@ public static HistoryItemResponse of(History history) {
: null;

AnswerInfo answerInfo = history.getAnswer() != null
? new AnswerInfo(history.getAnswer().getId())
? new AnswerInfo(
history.getAnswer().getId(),
truncate(history.getAnswer().getContent(), 100))
: null;

CommentInfo commentInfo = history.getComment() != null
? new CommentInfo(
history.getComment().getId(),
truncate(history.getComment().getContent(), 100))
: null;

Integer points = switch (history.getActionType()) {
case "ai_summary_viewed" -> PointAction.AI_SUMMARY_VIEW.getPoints();
case "scrapped" -> PointAction.CONTENT_SCRAP.getPoints();
case "content_liked" -> PointAction.CONTENT_LIKE.getPoints();
case "question_created" -> PointAction.QUESTION_WRITE.getPoints();
Expand All @@ -57,7 +66,13 @@ public static HistoryItemResponse of(History history) {
contentInfo,
postInfo,
answerInfo,
commentInfo,
history.getCreatedAt() != null ? history.getCreatedAt().toInstant(ZoneOffset.UTC) : null
);
}

private static String truncate(String text, int maxLength) {
if (text == null) return null;
return text.length() <= maxLength ? text : text.substring(0, maxLength) + "...";
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/devpick/domain/report/entity/History.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.devpick.domain.content.entity.Content;
import com.devpick.domain.community.entity.Answer;
import com.devpick.domain.community.entity.Comment;
import com.devpick.domain.community.entity.Post;
import com.devpick.domain.user.entity.User;
import com.devpick.global.entity.BaseCreatedEntity;
Expand Down Expand Up @@ -38,4 +39,11 @@ public class History extends BaseCreatedEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "answer_id")
private Answer answer;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;

@Column(name = "level", length = 20)
private String level;
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Page<UUID> findHistoryIdsByDateRangeExcludingContentLiked(
"LEFT JOIN FETCH h.content " +
"LEFT JOIN FETCH h.post " +
"LEFT JOIN FETCH h.answer " +
"LEFT JOIN FETCH h.comment " +
"WHERE h.id IN :ids " +
"ORDER BY h.createdAt DESC")
List<History> findHistoriesWithAssociationsByIds(@Param("ids") List<UUID> ids);
Expand All @@ -131,4 +132,15 @@ Page<UUID> findHistoryIdsByDateRangeExcludingContentLiked(
@Modifying
@Query("DELETE FROM History h WHERE h.answer.id IN (SELECT a.id FROM Answer a WHERE a.post.id = :postId)")
void deleteByAnswerPostId(@Param("postId") UUID postId);

@Modifying
@Query("DELETE FROM History h WHERE h.comment.id = :commentId")
void deleteByCommentId(@Param("commentId") UUID commentId);

@Modifying
@Query("DELETE FROM History h WHERE h.user.id = :userId AND h.content.id = :contentId AND h.actionType = :actionType")
void deleteByUserIdAndContentIdAndActionType(
@Param("userId") UUID userId,
@Param("contentId") UUID contentId,
@Param("actionType") String actionType);
}
5 changes: 5 additions & 0 deletions src/main/java/com/devpick/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ public void addPoints(int points) {
this.totalPoints += points;
}

/** 포인트 차감 (환불). */
public void subtractPoints(int points) {
this.totalPoints = Math.max(0, this.totalPoints - points);
}

/** 프로필 수정 (DP-187). */
public void updateProfile(String nickname, String profileImage, Job job, Level level) {
if (nickname != null) this.nickname = nickname;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.devpick.domain.point.entity.PointAction;
import com.devpick.domain.point.service.PointService;
import com.devpick.domain.report.entity.History;
import com.devpick.domain.report.repository.HistoryRepository;
import com.devpick.domain.user.dto.LoginRequest;
import com.devpick.domain.user.dto.LoginResponse;
import com.devpick.domain.user.dto.RecoverRequest;
Expand Down Expand Up @@ -31,6 +33,7 @@ public class AuthService {
private final EmailVerificationRedisService emailVerificationRedisService;
private final PointService pointService;
private final UserConsentRepository userConsentRepository;
private final HistoryRepository historyRepository;

/**
* 이메일 회원가입 (DP-177 수정 — 이메일 인증 후 가입 흐름).
Expand Down Expand Up @@ -93,6 +96,10 @@ public LoginResponse login(LoginRequest request) {
}

pointService.earn(user, PointAction.DAILY_LOGIN);
historyRepository.save(History.builder()
.user(user)
.actionType("daily_login")
.build());
return tokenService.issueTokenPair(user);
}

Expand Down
Loading
Loading