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
11 changes: 11 additions & 0 deletions app/src/main/resources/db/migration/V15__add_live_report_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE INDEX idx_weather_report_live_status_created
ON weather_report (moderation_status, created_at DESC, id DESC)
INCLUDE (
location_id,
author_type,
author_member_id,
author_anonymous_key,
temperature,
precipitation,
sunlight
);
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
appVersion=0.3.1
appVersion=0.4.0
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.nalssilog.common.response.CursorPage;
import com.nalssilog.report.api.dto.CreateReportRequest;
import com.nalssilog.report.api.dto.CreateReportFlagRequest;
import com.nalssilog.report.api.dto.LiveReportsResponse;
import com.nalssilog.report.api.dto.ReportFlagResponse;
import com.nalssilog.report.application.ActorRestrictionService;
import com.nalssilog.report.application.ReportFlagService;
Expand All @@ -18,7 +19,9 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
Expand Down Expand Up @@ -86,6 +89,19 @@ public CursorPage<ReportResponse> list(
return reportService.list(locationId, cursor, viewer, actorResolver.resolveForOwnership(memberId, httpRequest));
}

@GetMapping("/live")
public ResponseEntity<LiveReportsResponse> live(
@AuthenticationPrincipal Long memberId,
HttpServletRequest httpRequest
) {
ReportActor viewer = actorResolver.resolveForRead(memberId, httpRequest);

return ResponseEntity.ok()
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
.header(HttpHeaders.PRAGMA, "no-cache")
.body(reportService.live(viewer));
}

@GetMapping("/me")
public CursorPage<ReportResponse> myReports(
@AuthenticationPrincipal Long memberId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.nalssilog.report.api.dto;

import com.nalssilog.report.application.dto.LiveReportData;
import com.nalssilog.report.application.dto.LocationSummary;
import java.time.Instant;
import java.util.List;

public record LiveReportsResponse(List<Item> items) {

public LiveReportsResponse {
items = List.copyOf(items);
}

public record Item(
String reportId,
Location location,
String message,
Instant createdAt
) {

public static Item of(LiveReportData report, LocationSummary location, String message) {
return new Item(
String.valueOf(report.id()),
Location.from(location),
message,
report.createdAt());
}
}

public record Location(
String id,
String sido,
String sigungu,
String dong,
String label,
String shortLabel
) {

public static Location from(LocationSummary location) {
return new Location(
String.valueOf(location.id()),
location.sido(),
location.sigungu(),
location.dong(),
location.label(),
location.shortLabel());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.nalssilog.report.application;

import com.nalssilog.report.application.dto.LiveReportData;
import com.nalssilog.report.domain.Precipitation;
import com.nalssilog.report.domain.Sunlight;
import com.nalssilog.report.domain.Temperature;

final class LiveReportMessageResolver {

private LiveReportMessageResolver() {
}

static String resolve(LiveReportData report) {
if (report.precipitation() == Precipitation.HEAVY) {
return "비가 많이 와요";
}

if (report.precipitation() == Precipitation.LIGHT) {
return "비가 조금 와요";
}

if (report.temperature() == Temperature.HOT) {
return "더워요";
}

if (report.temperature() == Temperature.COLD) {
return "추워요";
}

if (report.sunlight() == Sunlight.STRONG) {
return "햇빛이 따가워요";
}

if (report.sunlight() == Sunlight.LOW) {
return "햇빛이 부족해요";
}

if (report.temperature() == Temperature.FRESH) {
return "선선해요";
}

if (report.sunlight() == Sunlight.MODERATE) {
return "햇빛이 적당해요";
}

return "지금 비가 안 와요";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import com.nalssilog.common.exception.NalssiLogException;
import com.nalssilog.common.response.CursorPage;
import com.nalssilog.member.application.dto.TermsAgreement;
import com.nalssilog.report.api.dto.LiveReportsResponse;
import com.nalssilog.report.api.dto.ReportResponse;
import com.nalssilog.report.api.dto.ThanksResponse;
import com.nalssilog.report.api.dto.WeatherStatsResponse;
import com.nalssilog.report.application.dto.AuthorInfo;
import com.nalssilog.report.application.dto.CreateReportCommand;
import com.nalssilog.report.application.dto.LiveReportData;
import com.nalssilog.report.application.dto.LocationSummary;
import com.nalssilog.report.application.dto.ReportActor;
import com.nalssilog.report.application.dto.ReportData;
Expand Down Expand Up @@ -40,6 +42,8 @@ public class ReportService {

private static final int PAGE_SIZE = 20;
private static final Duration STATS_WINDOW = Duration.ofHours(3);
private static final int LIVE_REPORT_LIMIT = 20;
private static final Duration LIVE_REPORT_WINDOW = Duration.ofHours(24);

private final WeatherReportRepository reportRepository;
private final ThanksRepository thanksRepository;
Expand Down Expand Up @@ -114,6 +118,30 @@ public CursorPage<ReportResponse> list(Long locationId, String cursor, ReportAct
return CursorPage.of(items, nextCursor);
}

public LiveReportsResponse live(ReportActor viewer) {
return liveAt(viewer, Instant.now());
}

LiveReportsResponse liveAt(ReportActor viewer, Instant now) {
List<LiveReportData> reports = reportRepository.findLiveReports(
now.minus(LIVE_REPORT_WINDOW), viewer, LIVE_REPORT_LIMIT);

if (reports.isEmpty()) {
return new LiveReportsResponse(List.of());
}

Map<Long, LocationSummary> locations = locationClient.getLocations(
reports.stream().map(LiveReportData::locationId).distinct().toList());
List<LiveReportsResponse.Item> items = reports.stream()
.map(report -> LiveReportsResponse.Item.of(
report,
locations.get(report.locationId()),
LiveReportMessageResolver.resolve(report)))
.toList();

return new LiveReportsResponse(items);
}

/**
* 특정 회원이 작성한 제보 목록(내 제보 / 회원별 제보 공용). 여러 지역에 걸치므로 지역은 배치로 enrich 한다.
* 대상 회원이 탈퇴 상태면 작성자 정보는 노출하지 않고 "익명 이웃"으로 렌더된다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.nalssilog.report.application.dto;

import com.nalssilog.report.domain.Precipitation;
import com.nalssilog.report.domain.Sunlight;
import com.nalssilog.report.domain.Temperature;
import java.time.Instant;

public record LiveReportData(
Long id,
Long locationId,
Temperature temperature,
Precipitation precipitation,
Sunlight sunlight,
Instant createdAt
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import static com.nalssilog.report.domain.QActorBlock.actorBlock;

import com.nalssilog.common.exception.NalssiLogException;
import com.nalssilog.report.application.dto.LiveReportData;
import com.nalssilog.report.application.dto.PopularLocationAggregate;
import com.nalssilog.report.application.dto.ReportData;
import com.nalssilog.report.application.dto.ReportActor;
Expand Down Expand Up @@ -145,6 +146,38 @@ public List<ReportData> findPage(
.toList();
}

public List<LiveReportData> findLiveReports(
Instant since,
ReportActor viewer,
int limit
) {
return queryFactory
.select(
weatherReport.id,
weatherReport.locationId,
weatherReport.temperature,
weatherReport.precipitation,
weatherReport.sunlight,
weatherReport.createdAt)
.from(weatherReport)
.where(
weatherReport.moderationStatus.eq(ModerationStatus.VISIBLE),
weatherReport.createdAt.goe(since),
withoutBlockRelation(viewer))
.orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc())
.limit(limit)
.fetch()
.stream()
.map(row -> new LiveReportData(
row.get(weatherReport.id),
row.get(weatherReport.locationId),
row.get(weatherReport.temperature),
row.get(weatherReport.precipitation),
row.get(weatherReport.sunlight),
row.get(weatherReport.createdAt)))
.toList();
}

public List<ReportData> findMemberPage(Long memberId, Instant cursorTime, Long cursorId, int limit) {
return findMemberPage(memberId, cursorTime, cursorId, null, limit);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package com.nalssilog.report.api;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.nalssilog.report.api.dto.LiveReportsResponse;
import com.nalssilog.report.application.ActorRestrictionService;
import com.nalssilog.report.application.ReportFlagService;
import com.nalssilog.report.application.ReportRateLimiter;
import com.nalssilog.report.application.ReportService;
import com.nalssilog.report.application.dto.ReportActor;
import com.nalssilog.report.config.ReportActorResolver;
import com.nalssilog.report.config.ReportClientIpResolver;
import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

@SuppressWarnings("java:S5960")
class ReportControllerLiveTest {

private final ReportService reportService = mock(ReportService.class);
private final ReportActorResolver actorResolver = mock(ReportActorResolver.class);
private final ReportController controller = new ReportController(
reportService,
actorResolver,
mock(ReportClientIpResolver.class),
mock(ReportRateLimiter.class),
mock(ActorRestrictionService.class),
mock(ReportFlagService.class));
private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
.build();

@Test
void reflectsOptionalMemberAuthenticationAndDisablesSharedCaching() {
MockHttpServletRequest request = new MockHttpServletRequest();
ReportActor viewer = ReportActor.member(7L);
LiveReportsResponse body = new LiveReportsResponse(List.of());

when(actorResolver.resolveForRead(7L, request)).thenReturn(viewer);
when(reportService.live(viewer)).thenReturn(body);

ResponseEntity<LiveReportsResponse> response = controller.live(7L, request);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL))
.isEqualTo("private, no-store");
assertThat(response.getHeaders().getFirst(HttpHeaders.PRAGMA)).isEqualTo("no-cache");
assertThat(response.getBody()).isSameAs(body);
verify(actorResolver).resolveForRead(7L, request);
verify(reportService).live(viewer);
}

@Test
void returnsThePublicLiveResponseContractForAnAnonymousRequest() throws Exception {
LiveReportsResponse body = new LiveReportsResponse(List.of(
new LiveReportsResponse.Item(
"987654321098765432",
new LiveReportsResponse.Location(
"123456789012345678",
"서울특별시",
"강남구",
"역삼동",
"서울특별시 강남구 역삼동",
"강남구 역삼동"),
"비가 많이 와요",
Instant.parse("2026-08-14T08:30:00Z"))));

when(actorResolver.resolveForRead(isNull(), any(HttpServletRequest.class)))
.thenReturn(null);
when(reportService.live(null)).thenReturn(body);

mockMvc.perform(get("/api/reports/live"))
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.CACHE_CONTROL, "private, no-store"))
.andExpect(header().string(HttpHeaders.PRAGMA, "no-cache"))
.andExpect(jsonPath("$.items[0].reportId").value("987654321098765432"))
.andExpect(jsonPath("$.items[0].location.id").value("123456789012345678"))
.andExpect(jsonPath("$.items[0].location.shortLabel").value("강남구 역삼동"))
.andExpect(jsonPath("$.items[0].message").value("비가 많이 와요"))
.andExpect(jsonPath("$.items[0].createdAt").value("2026-08-14T08:30:00Z"));
}
}
Loading