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
33 changes: 33 additions & 0 deletions docs/api-specs/perspectives-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,39 @@
}
```

---
## 주간 베스트 관점 조회 API
### `GET /api/v1/perspectives/weekly-best`

- 직전 완료된 한 주(월요일 00:00 ~ 일요일 24:00, KST) 동안 눌린 좋아요 수 기준으로 최대 25개를 반환합니다. 화면의 5개씩 페이지 처리는 앱에서 합니다.
- 정렬: 해당 주 좋아요 수 내림차순 → 최신 관점 → id 내림차순
- 대상: `PUBLISHED` 관점 중 공개 배틀의 일반 관점. 클래스 관점은 제외합니다.
- 토큰은 선택입니다. 토큰이 없으면 `participated` 는 모두 `false` 이고, 만료된 토큰을 보내면 401 입니다.
- `participated` 는 사후투표 완료 여부입니다. `true` 면 해당 관점으로, `false` 면 배틀 진입점으로 이동합니다.

#### 성공 응답 `200 OK`

```json
{
"statusCode": 200,
"data": {
"weekStart": "2026-11-02",
"weekEnd": "2026-11-08",
"items": [
{
"perspectiveId": 1,
"battleId": 10,
"battleTitle": "배틀 제목",
"content": "관점 본문",
"likeCount": 50,
"participated": true
}
]
},
"error": null
}
```

---
## 내 PENDING 관점 조회 API
### `GET /api/v1/battles/{battle_id}/perspectives/me/pending`
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/com/swyp/picke/domain/oauth/jwt/JwtFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ public JwtFilter(JwtProvider jwtProvider, UserDailyActivityRepository userDailyA
"/error"
);

/**
* 토큰이 없어도 통과하지만, 토큰이 있으면 검증해 사용자를 식별하는 경로.
* WHITELIST는 필터 자체를 건너뛰어 로그인 사용자도 식별되지 않으므로 분리한다.
*/
private static final Set<String> OPTIONAL_AUTH_PATHS = Set.of(
"/api/v1/perspectives/weekly-best"
);

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String requestUri = request.getRequestURI();
Expand Down Expand Up @@ -127,7 +135,7 @@ protected void doFilterInternal(HttpServletRequest request,
SecurityContextHolder.getContext().setAuthentication(authentication);
recordActivity(userId);

} else {
} else if (!OPTIONAL_AUTH_PATHS.contains(requestUri)) {
// shouldNotFilter가 잡지 못한 주소 중 토큰이 없다면 무조건 401 차단
log.warn("[JwtFilter] Token missing for secured URI: {}", requestUri);
setErrorResponse(response, ErrorCode.AUTH_UNAUTHORIZED);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import com.swyp.picke.domain.perspective.dto.response.PerspectiveDetailResponse;
import com.swyp.picke.domain.perspective.dto.response.PerspectiveListResponse;
import com.swyp.picke.domain.perspective.dto.response.UpdatePerspectiveResponse;
import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse;
import com.swyp.picke.domain.perspective.service.PerspectiveService;
import com.swyp.picke.domain.perspective.service.WeeklyBestPerspectiveService;
import com.swyp.picke.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -33,6 +35,14 @@
public class PerspectiveController {

private final PerspectiveService perspectiveService;
private final WeeklyBestPerspectiveService weeklyBestPerspectiveService;

@Operation(summary = "주간 베스트 관점 조회", description = "직전 한 주 동안 좋아요를 많이 받은 관점을 최대 25개 조회합니다. 토큰 없이 호출하면 participated 는 false 입니다.")
@GetMapping("/perspectives/weekly-best")
public ApiResponse<WeeklyBestPerspectiveResponse> getWeeklyBestPerspectives(
@AuthenticationPrincipal Long userId) {
return ApiResponse.onSuccess(weeklyBestPerspectiveService.getWeeklyBest(userId));
}

@Operation(summary = "관점 상세 조회", description = "특정 관점의 상세 정보를 조회합니다.")
@GetMapping("/perspectives/{perspectiveId}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.swyp.picke.domain.perspective.dto.response;

import java.time.LocalDate;
import java.util.List;

public record WeeklyBestPerspectiveResponse(
LocalDate weekStart,
LocalDate weekEnd,
List<Item> items
) {
public record Item(
Long perspectiveId,
Long battleId,
String battleTitle,
String content,
long likeCount,
boolean participated
) {}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.swyp.picke.domain.perspective.repository;

import com.swyp.picke.domain.battle.enums.BattleStatus;
import com.swyp.picke.domain.perspective.entity.Perspective;
import com.swyp.picke.domain.perspective.entity.PerspectiveLike;
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount;
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.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

Expand All @@ -33,5 +37,25 @@ select count(l) from PerspectiveLike l
long countReceivedByUserInClass(@org.springframework.data.repository.query.Param("classId") Long classId,
@org.springframework.data.repository.query.Param("userId") Long userId);

// 주간 베스트 관점: 기간 안에 눌린 좋아요 수 기준. 취소된 좋아요는 행이 삭제되어 자동으로 빠진다
@Query("""
select p.id as perspectiveId, count(l) as likeCount
from PerspectiveLike l
join l.perspective p
join p.battle b
where l.createdAt >= :start
and l.createdAt < :end
and p.classroom is null
and p.status = :perspectiveStatus
and b.status = :battleStatus
group by p.id, p.createdAt
order by count(l) desc, p.createdAt desc, p.id desc
""")
List<WeeklyPerspectiveLikeCount> findWeeklyLikeRanking(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("perspectiveStatus") PerspectiveStatus perspectiveStatus,
@Param("battleStatus") BattleStatus battleStatus,
Pageable pageable);

void deleteAllByPerspective(Perspective perspective);
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,7 @@ public interface PerspectiveRepository extends JpaRepository<Perspective, Long>

// 베댓 보상 배치: 일반 배틀 의견만
List<Perspective> findByBattleIdAndClassroomIsNullAndStatusOrderByLikeCountDescCreatedAtDesc(Long battleId, PerspectiveStatus status, Pageable pageable);

@Query("select p from Perspective p join fetch p.battle where p.id in :ids")
List<Perspective> findAllWithBattleByIdIn(@Param("ids") List<Long> ids);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.swyp.picke.domain.perspective.repository.projection;

public interface WeeklyPerspectiveLikeCount {

Long getPerspectiveId();

long getLikeCount();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.swyp.picke.domain.perspective.service;

import com.swyp.picke.domain.battle.enums.BattleStatus;
import com.swyp.picke.domain.perspective.dto.response.WeeklyBestPerspectiveResponse;
import com.swyp.picke.domain.perspective.entity.Perspective;
import com.swyp.picke.domain.perspective.enums.PerspectiveStatus;
import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository;
import com.swyp.picke.domain.perspective.repository.PerspectiveRepository;
import com.swyp.picke.domain.perspective.repository.projection.WeeklyPerspectiveLikeCount;
import com.swyp.picke.domain.user.enums.UserBattleStep;
import com.swyp.picke.domain.user.repository.UserBattleRepository;
import java.time.Clock;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 직전 완료된 한 주(월~일, KST) 동안 좋아요를 많이 받은 일반 배틀 관점을 조회한다.
* 집계 테이블 없이 기간을 고정해 계산하므로 숨김 처리된 관점은 바로 빠진다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WeeklyBestPerspectiveService {

private static final int MAX_ITEMS = 25;

private final PerspectiveLikeRepository perspectiveLikeRepository;
private final PerspectiveRepository perspectiveRepository;
private final UserBattleRepository userBattleRepository;
private final Clock clock;

public WeeklyBestPerspectiveResponse getWeeklyBest(Long userId) {
LocalDate thisMonday = LocalDate.now(clock).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
LocalDate weekStart = thisMonday.minusWeeks(1);

List<WeeklyPerspectiveLikeCount> ranking = perspectiveLikeRepository.findWeeklyLikeRanking(
weekStart.atStartOfDay(),
thisMonday.atStartOfDay(),
PerspectiveStatus.PUBLISHED,
BattleStatus.PUBLISHED,
PageRequest.of(0, MAX_ITEMS));
if (ranking.isEmpty()) {
return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), List.of());
}

Map<Long, Perspective> perspectives = perspectiveRepository
.findAllWithBattleByIdIn(ranking.stream().map(WeeklyPerspectiveLikeCount::getPerspectiveId).toList())
.stream()
.collect(Collectors.toMap(Perspective::getId, Function.identity()));
Set<Long> participatedBattleIds = findParticipatedBattleIds(userId, perspectives.values());

List<WeeklyBestPerspectiveResponse.Item> items = ranking.stream()
.map(rank -> {
Perspective perspective = perspectives.get(rank.getPerspectiveId());
Long battleId = perspective.getBattle().getId();
return new WeeklyBestPerspectiveResponse.Item(
perspective.getId(),
battleId,
perspective.getBattle().getTitle(),
perspective.getContent(),
rank.getLikeCount(),
participatedBattleIds.contains(battleId));
})
.toList();
return new WeeklyBestPerspectiveResponse(weekStart, thisMonday.minusDays(1), items);
}

// 참여 기준은 관점 작성 조건과 같은 사후투표 완료(COMPLETED)다. 비로그인은 미참여로 본다.
private Set<Long> findParticipatedBattleIds(Long userId, java.util.Collection<Perspective> perspectives) {
if (userId == null) {
return Set.of();
}
Set<Long> battleIds = perspectives.stream()
.map(perspective -> perspective.getBattle().getId())
.collect(Collectors.toSet());
return userBattleRepository.findBattleIdsByUserAndStep(userId, battleIds, UserBattleStep.COMPLETED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,16 @@ Set<Long> findClassroomIdsWithActivity(@Param("userId") Long userId,

// 클래스 결과 집계·리포트 대상 선별: 이 클래스 안의 진행 기록 전체 (user 는 LAZY)
List<UserBattle> findByClassroomId(Long classroomId);

// 일반 배틀 진행 단계가 step 인 배틀만 추린다
@Query("""
select ub.battle.id from UserBattle ub
where ub.user.id = :userId
and ub.classroom is null
and ub.step = :step
and ub.battle.id in :battleIds
""")
Set<Long> findBattleIdsByUserAndStep(@Param("userId") Long userId,
@Param("battleIds") Collection<Long> battleIds,
@Param("step") com.swyp.picke.domain.user.enums.UserBattleStep step);
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

// 3. 단순 조회성 및 진행상태 업데이트 REST API
.requestMatchers(HttpMethod.GET, "/api/v1/share/recap/*").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/perspectives/weekly-best").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/tags", "/api/v1/battles/**").authenticated()
.requestMatchers(HttpMethod.POST, "/api/v1/battles/**").authenticated()

Expand Down
Loading
Loading