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
24 changes: 24 additions & 0 deletions docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,8 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
- 나간 뒤에도 같은 코드로 다시 참여할 수 있다.
- 투표·의견 등 활동 이력이 있으면 좌석은 반환되지 않는다.

계정 탈퇴 시에도 같은 좌석 규칙을 적용한다. 탈퇴자가 운영자인 ACTIVE 클래스는 소프트 삭제하고, 참여 중인 멤버십은 `LEFT`로 바꾼다. 탈퇴자가 쓴 의견·댓글은 숨김 처리하며 이력 참조를 위해 DB 행은 보존한다.

| 상황 | 응답 |
|---|---|
| 운영자 (나갈 수 없고 클래스를 삭제해야 함) | 403 `CLASS_403_OWNER_LEAVE` |
Expand Down Expand Up @@ -1075,6 +1077,28 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접
- `coverage=partial`은 분량 제한으로 일부 글을 빼고 분석했다는 뜻이다. 뺀 글 수는 `excludedCount`에 있다.
- 참여하지 않는 운영자는 403 `CLASS_403_MEMBER`다.

### 8.2.1 내 활동 (리포트 화면)
- `GET /api/v1/classes/{classId}/result/my-activity?cursor={createdAt}&size=20`
- 참여 중인 멤버만 조회할 수 있다. 기본 페이지 크기는 20, 최대 100이다.
- `receivedLikeCount`는 공개된 내 의견·댓글에 받은 좋아요 수 합계다. `comments`는 공개 상태인 내 댓글 목록이며, 최신순 커서 페이지네이션을 사용한다.
- 잘못된 `cursor`는 400 `COMMON_400`이다.

```json
{
"statusCode": 200,
"data": {
"classId": 5,
"receivedLikeCount": 12,
"commentCount": 2,
"comments": [
{ "commentId": 61, "perspectiveId": 50, "perspectiveTitle": "창작이다", "stance": "...", "content": "...", "likeCount": 2, "createdAt": "2026-10-21T12:00:00" }
],
"nextCursor": null,
"hasNext": false
}
}
```

### 8.3 클래스 인사이트 (운영자)
- `GET /api/v1/classes/{classId}/result/insight`
- 공개 의견을 요약한 AI 인사이트와 근거·대표 의견이다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.swyp.picke.domain.classroom.dto.response.ClassInsightResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyReportResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyActivityResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassResultResponse;
import com.swyp.picke.domain.classroom.service.ClassResultService;
import com.swyp.picke.global.common.response.ApiResponse;
Expand Down Expand Up @@ -49,6 +50,18 @@ public ApiResponse<ClassMyReportResponse> getMyReport(
return ApiResponse.onSuccess(classResultService.getMyReport(userId, classId));
}

@Operation(summary = "내 활동 (리포트 화면)",
description = "공개된 의견·댓글에 받은 좋아요 수와 내가 쓴 공개 댓글 목록을 커서 기반으로 조회합니다. 참여 멤버 전용이며, 기본 20건·최대 100건입니다.")
@GetMapping("/my-activity")
public ApiResponse<ClassMyActivityResponse> getMyActivity(
@AuthenticationPrincipal Long userId,
@PathVariable Long classId,
@org.springframework.web.bind.annotation.RequestParam(required = false) String cursor,
@org.springframework.web.bind.annotation.RequestParam(required = false) Integer size
) {
return ApiResponse.onSuccess(classResultService.getMyActivity(userId, classId, cursor, size));
}

@Operation(summary = "클래스 인사이트 (운영자)",
description = "공개 의견을 요약한 AI 인사이트와 근거·대표 의견. 운영자가 아니면 403 CLASS_403_OWNER. "
+ "작성자 3명 미만이면 LIMITED, 생성 실패면 FAILED + retryable.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.swyp.picke.domain.classroom.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;

@Schema(description = "클래스 리포트 화면용 내 활동. 받은 좋아요는 공개된 의견·댓글에 받은 수의 합계")
public record ClassMyActivityResponse(
Long classId,
long receivedLikeCount,
long commentCount,
List<Comment> comments,
String nextCursor,
boolean hasNext
) {
public record Comment(
Long commentId,
Long perspectiveId,
String perspectiveTitle,
String stance,
String content,
int likeCount,
LocalDateTime createdAt
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.swyp.picke.domain.classroom.dto.response.ClassInsightResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyReportResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyActivityResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassResultResponse;

/**
Expand All @@ -14,6 +15,8 @@ public interface ClassResultService {

ClassMyReportResponse getMyReport(Long userId, Long classId);

ClassMyActivityResponse getMyActivity(Long userId, Long classId, String cursor, Integer size);

ClassInsightResponse getInsight(Long userId, Long classId);

ClassInsightResponse retryInsight(Long userId, Long classId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.swyp.picke.domain.battle.entity.BattleOption;
import com.swyp.picke.domain.classroom.dto.response.ClassInsightResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyReportResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassMyActivityResponse;
import com.swyp.picke.domain.classroom.dto.response.ClassResultResponse;
import com.swyp.picke.domain.classroom.entity.ClassMember;
import com.swyp.picke.domain.classroom.entity.ClassReport;
Expand All @@ -23,11 +24,14 @@
import com.swyp.picke.domain.perspective.entity.PerspectiveComment;
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository;
import com.swyp.picke.domain.perspective.repository.CommentLikeRepository;
import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository;
import com.swyp.picke.domain.perspective.repository.PerspectiveRepository;
import com.swyp.picke.domain.vote.entity.BattleVote;
import com.swyp.picke.domain.vote.repository.BattleVoteRepository;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import com.swyp.picke.global.common.util.CursorParser;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
Expand All @@ -37,6 +41,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.PageRequest;

/**
* 결과 화면 조회. 저장된 리포트 content(JSON)를 응답으로 바꾸고, 근거 글은 현재 상태로 다시 읽어 삭제·숨김된 글은 뺀다.
Expand All @@ -58,6 +63,11 @@ public class ClassResultServiceImpl implements ClassResultService {
private final BattleVoteRepository battleVoteRepository;
private final PerspectiveRepository perspectiveRepository;
private final PerspectiveCommentRepository perspectiveCommentRepository;
private final PerspectiveLikeRepository perspectiveLikeRepository;
private final CommentLikeRepository commentLikeRepository;

private static final int DEFAULT_ACTIVITY_PAGE_SIZE = 20;
private static final int MAX_ACTIVITY_PAGE_SIZE = 100;

@Override
public ClassResultResponse getResult(Long userId, Long classId) {
Expand Down Expand Up @@ -120,6 +130,43 @@ public ClassMyReportResponse getMyReport(Long userId, Long classId) {
perspectiveCount, commentCount, feedback, generatedAt);
}

@Override
public ClassMyActivityResponse getMyActivity(Long userId, Long classId, String cursor, Integer size) {
ClassAccess access = classAccessService.requireResultReadable(classId, userId, LocalDateTime.now(KST));
if (access.member() == null) {
throw new CustomException(ErrorCode.CLASS_MEMBER_ONLY);
}

int pageSize = size == null || size <= 0
? DEFAULT_ACTIVITY_PAGE_SIZE
: Math.min(size, MAX_ACTIVITY_PAGE_SIZE);
PageRequest pageable = PageRequest.of(0, pageSize);
LocalDateTime before = CursorParser.parseDateTime(cursor);
List<PerspectiveComment> comments = before == null
? perspectiveCommentRepository.findVisibleByClassroomIdAndUserIdOrderByCreatedAtDesc(classId, userId, pageable)
: perspectiveCommentRepository.findVisibleByClassroomIdAndUserIdBeforeOrderByCreatedAtDesc(
classId, userId, before, pageable);

List<ClassMyActivityResponse.Comment> items = comments.stream()
.map(comment -> new ClassMyActivityResponse.Comment(
comment.getId(),
comment.getPerspective().getId(),
comment.getPerspective().getOption().getTitle(),
comment.getPerspective().getOption().getStance(),
comment.getContent(),
comment.getLikeCount(),
comment.getCreatedAt()))
.toList();
String nextCursor = comments.size() == pageSize
? comments.get(comments.size() - 1).getCreatedAt().toString()
: null;
long receivedLikeCount = perspectiveLikeRepository.countReceivedByUserInClass(classId, userId)
+ commentLikeRepository.countReceivedByUserInClass(classId, userId);
long commentCount = perspectiveCommentRepository.countVisibleByClassroomIdAndUserId(classId, userId);
return new ClassMyActivityResponse(classId, receivedLikeCount, commentCount, items,
nextCursor, nextCursor != null);
}

@Override
public ClassInsightResponse getInsight(Long userId, Long classId) {
LocalDateTime now = LocalDateTime.now(KST);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.swyp.picke.domain.perspective.entity.CommentLike;
import com.swyp.picke.domain.perspective.entity.PerspectiveComment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
Expand All @@ -16,4 +18,13 @@ public interface CommentLikeRepository extends JpaRepository<CommentLike, Long>
void deleteAllByCommentIn(List<PerspectiveComment> comments);

void deleteAllByComment(PerspectiveComment comment);

@Query("""
select count(l) from CommentLike l
where l.comment.classroom.id = :classId
and l.comment.user.id = :userId
and l.comment.hidden = false
and l.comment.perspective.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED
""")
long countReceivedByUserInClass(@Param("classId") Long classId, @Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.swyp.picke.domain.perspective.entity.PerspectiveComment;
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;

Expand Down Expand Up @@ -34,6 +35,40 @@ public interface PerspectiveCommentRepository extends JpaRepository<PerspectiveC

List<PerspectiveComment> findByClassroomIdAndUserIdAndHiddenFalseOrderByCreatedAtAsc(Long classroomId, Long userId);

@Modifying(flushAutomatically = true)
@Query("update PerspectiveComment c set c.hidden = true where c.user.id = :userId and c.hidden = false")
int hideAllByUserId(@Param("userId") Long userId);

@Query("""
select c from PerspectiveComment c
join fetch c.perspective p
where c.classroom.id = :classId and c.user.id = :userId
and c.hidden = false and p.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED
order by c.createdAt desc, c.id desc
""")
List<PerspectiveComment> findVisibleByClassroomIdAndUserIdOrderByCreatedAtDesc(
@Param("classId") Long classId, @Param("userId") Long userId, Pageable pageable);

@Query("""
select c from PerspectiveComment c
join fetch c.perspective p
where c.classroom.id = :classId and c.user.id = :userId
and c.hidden = false and p.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED
and c.createdAt < :cursor
order by c.createdAt desc, c.id desc
""")
List<PerspectiveComment> findVisibleByClassroomIdAndUserIdBeforeOrderByCreatedAtDesc(
@Param("classId") Long classId, @Param("userId") Long userId,
@Param("cursor") LocalDateTime cursor, Pageable pageable);

@Query("""
select count(c) from PerspectiveComment c
join c.perspective p
where c.classroom.id = :classId and c.user.id = :userId
and c.hidden = false and p.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED
""")
long countVisibleByClassroomIdAndUserId(@Param("classId") Long classId, @Param("userId") Long userId);

@Query("SELECT c FROM PerspectiveComment c WHERE c.perspective.battle.id = :battleId AND c.classroom IS NULL AND c.hidden = false AND c.likeCount >= :minLikeCount ORDER BY c.likeCount DESC")
List<PerspectiveComment> findTopCommentsByBattleId(@Param("battleId") Long battleId, @Param("minLikeCount") int minLikeCount, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
Expand All @@ -23,5 +24,14 @@ public interface PerspectiveLikeRepository extends JpaRepository<PerspectiveLike

long countByUserId(Long userId);

@Query("""
select count(l) from PerspectiveLike l
where l.perspective.classroom.id = :classId
and l.perspective.user.id = :userId
and l.perspective.status = com.swyp.picke.domain.perspective.enums.PerspectiveStatus.PUBLISHED
""")
long countReceivedByUserInClass(@org.springframework.data.repository.query.Param("classId") Long classId,
@org.springframework.data.repository.query.Param("userId") Long userId);

void deleteAllByPerspective(Perspective perspective);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
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;
Expand All @@ -25,6 +28,10 @@ public interface PerspectiveRepository extends JpaRepository<Perspective, Long>

List<Perspective> findByClassroomIdAndUserIdAndStatusOrderByCreatedAtAsc(Long classroomId, Long userId, PerspectiveStatus status);

@Modifying(flushAutomatically = true)
@Query("update Perspective p set p.status = :hidden where p.user.id = :userId and p.status <> :hidden")
int hideAllByUserId(@Param("userId") Long userId, @Param("hidden") PerspectiveStatus hidden);

// 마이페이지: 일반 배틀 의견만
List<Perspective> findByUserIdAndClassroomIsNullOrderByCreatedAtDesc(Long userId, Pageable pageable);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@
import com.swyp.picke.domain.user.entity.User;
import com.swyp.picke.domain.user.repository.UserRepository;
import com.swyp.picke.domain.user.enums.CharacterType;
import com.swyp.picke.domain.user.enums.UserStatus;
import com.swyp.picke.domain.user.service.UserService;
import com.swyp.picke.domain.vote.service.BattleVoteService;
import com.swyp.picke.global.common.exception.CustomException;
import com.swyp.picke.global.common.exception.ErrorCode;
import com.swyp.picke.global.infra.s3.service.S3PresignedUrlService;
import com.swyp.picke.global.common.util.CursorParser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -71,8 +73,11 @@ public CreateCommentResponse createComment(Long perspectiveId, Long userId, Crea
Classroom classroom = perspective.getClassroom();
requireClassWritable(classroom, userId);
requireVisibleAuthor(perspective, classroom);
User user = userRepository.findById(userId)
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if (user.getStatus() != UserStatus.ACTIVE) {
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}

PerspectiveComment comment = PerspectiveComment.builder()
.perspective(perspective)
Expand Down Expand Up @@ -147,7 +152,7 @@ private CommentListResponse listComments(Long perspectiveId, Long userId, String
List<PerspectiveComment> comments = cursor == null
? commentRepository.findByPerspectiveOrderByCreatedAtDesc(perspective, pageable)
: commentRepository.findByPerspectiveAndCreatedAtBeforeOrderByCreatedAtDesc(
perspective, LocalDateTime.parse(cursor), pageable);
perspective, CursorParser.parseDateTime(cursor), pageable);

Long battleId = perspective.getBattle().getId();
// 클래스 댓글이면 작성자를 클래스 표시 이름으로 보여주고, 나간·내보낸 멤버의 댓글은 뺀다
Expand Down Expand Up @@ -204,6 +209,7 @@ public void deleteComment(Long perspectiveId, Long commentId, Long userId) {

@Transactional
public UpdateCommentResponse updateComment(Long perspectiveId, Long commentId, Long userId, UpdateCommentRequest request) {
requireActiveUserForWrite(userId);
Perspective perspective = findPerspectiveById(perspectiveId);
PerspectiveComment comment = findCommentById(commentId);
validateOwnership(comment, userId);
Expand All @@ -213,6 +219,14 @@ public UpdateCommentResponse updateComment(Long perspectiveId, Long commentId, L
return new UpdateCommentResponse(comment.getId(), comment.getContent(), comment.getUpdatedAt());
}

private void requireActiveUserForWrite(Long userId) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if (user.getStatus() != UserStatus.ACTIVE) {
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
}

/** 댓글 작성자의 사후투표 입장. 클래스 의견이면 같은 클래스 안의 투표만 본다 */
private String resolveStance(Long battleId, Long authorId, Classroom classroom) {
Long postVoteOptionId = BattleVoteService.findPostVoteOptionId(battleId, authorId, classroom);
Expand Down
Loading
Loading