From 0c3713fdcce546e29b0b4d4078ee4a15d8dbf3ca Mon Sep 17 00:00:00 2001 From: MINYONG PARK Date: Fri, 7 Aug 2026 17:40:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705-397):=20=EB=82=98=EC=9D=98=20?= =?UTF-8?q?=ED=99=9C=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/me/activity 하나로 totals·months·areas·counts·highlights 다섯 덩어리를 내려준다. 기간은 전체 누적 고정이며 기간 파라미터를 두지 않는다. 새 테이블·인덱스· 마이그레이션은 없다. 날짜 경계를 전부 KST로 고정했다. created_at이 TIMESTAMPTZ라 date_trunc를 그냥 쓰면 서버 타임존을 타고, KST 자정 직후에 남긴 기록이 전날에 붙어 "가장 붐빈 하루"가 하루씩 밀린다. 경계에 걸친 시각을 넣은 테스트로 고정했다. 빈 달 채우기는 SQL이 아니라 서비스가 한다. 구간의 끝인 "이번 달"을 SQL에서 정하면 DB 서버의 시계가 응답을 좌우한다. areas의 동점 정렬에 COLLATE "C"를 명시했다. 한글 이름을 DB 기본 collation으로 정렬하면 운영 DB와 테스트 컨테이너의 locale이 달라 순서가 갈린다(388에서 같은 함정을 만나 그때는 이름 정렬 자체를 피했다). 엔티티를 거치지 않는 SQL이라 @SQLRestriction이 걸리지 않는다 — JPA 리포지토리와 반대로 deleted_at IS NULL을 직접 적었다. 명세: Team-PinLog/docs#54 Co-Authored-By: Claude Opus 5 --- ...26-08-07-S15P11A705-397-me-activity-api.md | 53 +++ .../member/controller/MeController.java | 20 +- .../domain/member/dto/MeActivityResponse.java | 70 ++++ .../repository/MemberActivityRepository.java | 162 +++++++++ .../member/service/MemberActivityService.java | 99 ++++++ .../record/repository/ContextRepository.java | 12 + .../domain/member/MeActivityApiTests.java | 309 ++++++++++++++++++ 7 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md create mode 100644 src/main/java/com/pinlog/pinlogback/domain/member/dto/MeActivityResponse.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberActivityRepository.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/member/service/MemberActivityService.java create mode 100644 src/test/java/com/pinlog/pinlogback/domain/member/MeActivityApiTests.java diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md b/docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md new file mode 100644 index 00000000..9c5b01f8 --- /dev/null +++ b/docs/backend/worklog/2026-08-07-S15P11A705-397-me-activity-api.md @@ -0,0 +1,53 @@ +# 나의 활동 기록 집계 API를 더했다 — 날짜 경계를 KST로 고정한다 + +- **날짜**: 2026-08-07 +- **추적**: S15P11A705-397 +- **관련**: `GET /v1/me/activity` · [S15P11A705-388](https://ssafy.atlassian.net/browse/S15P11A705-388)(한글 정렬 함정의 출처) + +발표 시연에서 화면을 채울 "나의 활동 기록" 페이지를 붙이려는데, 내 기록을 집계해 내려주는 경로가 없었다. 지금까지는 단건·목록 조회뿐이라 프론트가 기록을 전부 받아 직접 세야 했다. `GET /v1/me/activity` 하나로 다섯 덩어리(`totals`·`months`·`areas`·`counts`·`highlights`)를 내려준다. + +## 기간을 전체 누적으로 고정한 이유 + +설계 단계에서 목업 제목("2026년, 당신의 기록")과 본문("첫 기록 2026-01-14부터 8개월")이 어긋나 있었다. 올해만·최근 12개월 롤링·전체 누적 셋 중 **전체 누적**으로 정했다. 기간 파라미터를 두지 않는다 — 화면이 "지금까지"를 보여주는 자리라 범위를 고를 여지가 없고, 파라미터를 열면 캐시·검증·문서가 함께 늘어난다. + +## 날짜 경계는 전부 KST다 + +이 티켓에서 가장 조용히 틀릴 수 있는 자리다. `record.created_at`은 `TIMESTAMPTZ`라 `date_trunc`를 그냥 쓰면 서버 타임존을 탄다. KST 2026-05-18 00:30에 남긴 기록은 UTC로 2026-05-17 15:30이라, UTC로 끊으면 "가장 붐빈 하루"가 하루씩 밀린다. 한국 서비스에서 사용자의 하루는 KST의 하루이므로 월·일 집계를 전부 `created_at AT TIME ZONE 'Asia/Seoul'` 위에서 계산한다. + +경계에 걸친 시각을 일부러 넣은 테스트를 뒀다(`dayBoundaryFollowsSeoulTime`) — UTC로 끊는 구현이면 17일이 3건이 되어 실패한다. + +## 빈 달 채우기를 DB가 아니라 애플리케이션에서 한다 + +`months`는 첫 기록이 있는 달부터 **이번 달까지** 빠짐없이 이어져야 한다. 막대그래프가 x축을 건너뛰면 "그 달에 안 다녔다"가 아니라 "그 달이 없다"로 읽히기 때문이다(3월과 5월만 있으면 두 막대가 붙어 보인다). + +문제는 구간의 끝인 "이번 달"이다. `generate_series`로 SQL에서 채우면 그 값을 DB 서버의 시계와 타임존이 정하게 된다. 그래서 SQL은 **기록이 있는 달만** 주고, 채우는 일은 서비스가 `YearMonth.now(KST)`를 기준으로 한다 — 기준이 한 곳에 남는다. + +`counts.recordedMonthCount`(기록이 실제로 있는 달의 수)와 `months`의 길이는 다르다. 후자는 빈 달까지 채운 구간 길이다. 화면이 두 숫자를 다 쓴다("8개월" 옆에 "기록한 달 7개월"). + +## 자치구는 주소 문자열을 자른다 + +`core.place`에 행정구역 컬럼이 없어 `split_part(address, ' ', 2)`로 두 번째 조각을 시·구로 본다. "서울 마포구 성미산로 198" 같은 형식을 전제한 값이라 도로명·지번이 섞이거나 형식을 벗어난 주소에서는 정확하지 않다. 조각이 없으면 `nullif(..., '')`로 `NULL`이 되어 `areas`와 `districtCount` 양쪽에서 자연히 빠진다. + +좌표 클러스터링이면 "성수동"처럼 구보다 작은 단위까지 잡히지만 카드에 붙일 **이름**이 나오지 않는다. 화면이 "마포구"라는 라벨을 요구하므로 주소 파싱을 골랐다. + +## 동점 정렬에 `COLLATE "C"`를 명시한다 + +`areas`의 건수 동점은 지역명으로 끊는다. 그런데 한글 이름을 DB 기본 collation으로 정렬하면 **운영 DB와 테스트 컨테이너의 locale이 달라 순서가 갈린다** — S15P11A705-388이 같은 함정을 만나 그때는 이름 정렬 자체를 피했다(`keywordId`로 끊었다). 여기서는 파생 문자열이라 이름 말고 끊을 키가 없으므로, 피하는 대신 `ORDER BY count(*) DESC, district COLLATE "C" ASC`로 collation을 명시해 고정했다. + +## 집계를 한 쿼리로 묶지 않았다 + +서로 다른 테이블의 독립 집계라 조인이 카운트를 곱한다(Record 3건 × Collection 2건 = 6). `MemberSummaryService`가 같은 이유로 이미 카운트 넷을 따로 내고 있고, 진입당 1회 호출되는 경로에서 인덱스를 타는 집계 여섯은 그 복잡도를 살 이유가 되지 않는다. + +대신 대상 집합의 정의는 한 곳에 뒀다 — `MY_RECORDS` CTE(내 활성 Record + 장소 + KST 벽시계 + 시·구)를 네 쿼리가 공유한다. + +**엔티티를 거치지 않으므로 `@SQLRestriction`이 걸리지 않는다.** JPA 리포지토리에서는 삭제 조건을 적지 않는 것이 규약인데(S15P11A705-200), 이 SQL에서는 정반대로 `deleted_at IS NULL`을 직접 적어야 한다. 맥락 메모·컬렉션 카운트는 JPA 파생 쿼리를 쓰므로 그쪽은 조건을 적지 않는다. + +## 범위 밖 + +- **AI 키워드**("가장 자주 쓴 표현"): `ai` 스키마 소관이라 쓰지 않는다. +- **장소 분류**(카페·식당): `core.place`에 category 컬럼이 없다. +- **지도**: 화면에서 빼기로 해 좌표를 응답에 싣지 않는다. 설계 과정에서 동네별 정적 SVG 지도를 검토했다가, 프론트 공수가 가장 큰 조각이라 기능 축소 결정으로 걷어냈다. + +## 검증 + +`./gradlew clean check --no-daemon` 통과. 전체 683 테스트 0 실패다. 새 테스트 13개는 `MeActivityApiTests`에 있고, 마이그레이션·신규 인덱스는 없다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java b/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java index 3856b49e..392b5bd5 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java +++ b/src/main/java/com/pinlog/pinlogback/domain/member/controller/MeController.java @@ -5,8 +5,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse; import com.pinlog.pinlogback.domain.member.dto.MeSummaryResponse; import com.pinlog.pinlogback.domain.member.dto.WithdrawalStartResponse; +import com.pinlog.pinlogback.domain.member.service.MemberActivityService; import com.pinlog.pinlogback.domain.member.service.MemberSummaryService; import com.pinlog.pinlogback.domain.member.service.WithdrawalAuthorizationService; import com.pinlog.pinlogback.global.security.authentication.LoginMember; @@ -24,13 +26,16 @@ public class MeController { private final WithdrawalAuthorizationService withdrawalAuthorizationService; private final MemberSummaryService memberSummaryService; + private final MemberActivityService memberActivityService; public MeController( WithdrawalAuthorizationService withdrawalAuthorizationService, - MemberSummaryService memberSummaryService + MemberSummaryService memberSummaryService, + MemberActivityService memberActivityService ) { this.withdrawalAuthorizationService = withdrawalAuthorizationService; this.memberSummaryService = memberSummaryService; + this.memberActivityService = memberActivityService; } /** 마이페이지 요약(API 명세 3.5). 진입 시 1회 호출한다. */ @@ -39,6 +44,19 @@ public MeSummaryResponse summary(@LoginMember MemberPrincipal me) { return memberSummaryService.summarize(me.memberId()); } + /** + * 나의 활동 기록 집계(S15P11A705-397). 진입 시 1회 호출한다. + * + *

{@code /summary}를 대체하지 않고 옆에 둔다 — 그쪽은 계정 정보와 카운트 넷이고, 이쪽은 + * 월별·지역별 집계라 호출 시점과 응답 크기가 다르다. + * + *

기간 파라미터가 없다. 전체 누적 고정이다. + */ + @GetMapping("/activity") + public MeActivityResponse activity(@LoginMember MemberPrincipal me) { + return memberActivityService.summarize(me.memberId()); + } + /** * 회원 탈퇴 시작. 여기서는 아무것도 지우지 않고 공급자 인가 URL만 돌려준다(BD-48). * diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/dto/MeActivityResponse.java b/src/main/java/com/pinlog/pinlogback/domain/member/dto/MeActivityResponse.java new file mode 100644 index 00000000..70d0baf7 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/dto/MeActivityResponse.java @@ -0,0 +1,70 @@ +package com.pinlog.pinlogback.domain.member.dto; + +import java.time.LocalDate; +import java.util.List; + +/** + * 나의 활동 기록 집계 응답(S15P11A705-397). 화면 진입 시 1회 호출된다. + * + *

{@code memberId}를 담지 않는다. 개인 API는 서버가 쿠키로 사용자를 식별하므로 + * 클라이언트가 자신의 내부 ID를 알 필요가 없다(08 §1.1, BD-14 식별자 은닉). + * + *

기간은 전체 누적이다. 기간 파라미터를 두지 않는다 — 화면이 "지금까지"를 보여주는 + * 자리라 범위를 고를 여지가 없고, 파라미터를 열면 캐시·검증·문서가 함께 늘어난다. + * + *

날짜 경계는 전부 KST다. {@code record.created_at}은 {@code TIMESTAMPTZ}라 UTC로 + * 끊으면 KST 자정 직후의 기록이 전날로 붙는다. 월·일 집계는 모두 + * {@code created_at AT TIME ZONE 'Asia/Seoul'} 위에서 계산한다. + * + * @param totals 화면 상단의 큰 숫자 + * @param months 첫 기록이 있는 달부터 이번 달까지. 기록이 없는 달도 0으로 채워 빈칸 없이 이어진다 + * @param areas 주소의 시·구 기준 상위 5곳 + * @param counts 기록에 딸린 것들의 개수 + * @param highlights 기록에서 그대로 뽑은 단일 사실들 + */ +public record MeActivityResponse( + Totals totals, + List months, + List areas, + Counts counts, + Highlights highlights +) { + + /** + * @param placeCount 기록한 장소 수. 활성 Record 수와 같다 — {@code uq_record_active}가 + * 회원·장소당 활성 Record를 1개로 묶어 두기 때문이다(V3:43) + * @param districtCount 발자국이 닿은 자치구 수. 주소에서 시·구를 못 뽑는 행은 세지 않는다 + * @param firstRecordedOn 첫 기록일(KST). 기록이 없으면 {@code null} + */ + public record Totals(long placeCount, long districtCount, LocalDate firstRecordedOn) { + } + + /** @param month {@code YYYY-MM}(KST) */ + public record MonthCount(String month, long recordCount) { + } + + /** @param district 주소에서 뽑은 시·구 이름 */ + public record AreaCount(String district, long recordCount) { + } + + /** + * @param contextCount 활성 맥락 메모 수 + * @param collectionCount 활성 컬렉션 수 + * @param recordedMonthCount 기록이 실제로 있는 달의 수. {@code months}의 길이와 다르다 — + * 그쪽은 빈 달까지 채운 구간 길이다 + */ + public record Counts(long contextCount, long collectionCount, long recordedMonthCount) { + } + + /** + * @param firstPlaceName 처음 기록한 장소 이름. 기록이 없으면 {@code null} + * @param lastPlaceName 가장 최근에 기록한 장소 이름. 기록이 없으면 {@code null} + * @param busiestDay 하루에 가장 많이 기록한 날. 기록이 없으면 {@code null} + */ + public record Highlights(String firstPlaceName, String lastPlaceName, BusiestDay busiestDay) { + } + + /** @param date 그 날짜(KST) */ + public record BusiestDay(LocalDate date, long recordCount) { + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberActivityRepository.java b/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberActivityRepository.java new file mode 100644 index 00000000..d2d24a47 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/repository/MemberActivityRepository.java @@ -0,0 +1,162 @@ +package com.pinlog.pinlogback.domain.member.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.AreaCount; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.BusiestDay; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.MonthCount; + +/** + * 나의 활동 기록 집계 쿼리(S15P11A705-397). 전부 {@code core} 스키마만 읽는다. + * + *

JPQL이 아니라 SQL로 두는 이유는 세 가지다 — {@code date_trunc}·{@code split_part} 같은 + * PostgreSQL 함수가 필요하고, 결과가 엔티티가 아니라 집계 투영이며, 아래 {@code MY_RECORDS} CTE를 + * 네 쿼리가 공유해 대상 집합의 정의를 한 곳에 두기 위해서다. + * + *

{@code AT TIME ZONE 'Asia/Seoul'}이 이 클래스의 핵심이다. {@code created_at}은 + * {@code TIMESTAMPTZ}라 그냥 {@code date_trunc}하면 서버 타임존을 탄다. KST 자정 직후에 남긴 + * 기록(= UTC로는 전날 오후)이 전날에 붙어 "가장 붐빈 하루"가 하루씩 밀린다. + * + *

삭제 조건을 SQL에 직접 적는다. 엔티티를 거치지 않으므로 {@code @SQLRestriction}이 + * 걸리지 않는다 — JPA 리포지토리에서 조건을 생략해도 되는 것과 정반대다. + */ +@Repository +public class MemberActivityRepository { + + /** + * 집계 대상 집합. 내 활성 Record에 장소를 붙이고 KST 벽시계 시각을 미리 만들어 둔다. + * + *

{@code district}를 여기서 한 번만 뽑는다. 주소는 "서울 마포구 성미산로 198"처럼 공백으로 + * 끊긴 문자열이라 두 번째 조각이 시·구다. {@code nullif(..., '')}로 조각이 없는 주소를 + * {@code NULL}로 만들어 집계에서 자연히 빠지게 한다 — 도로명·지번이 섞이거나 형식을 벗어난 + * 주소는 이 방식으로 정확히 뽑히지 않으며, 그 한계를 안고 쓰는 값이다(티켓 상세 내용). + */ + private static final String MY_RECORDS = """ + WITH my AS ( + SELECT r.id AS record_id, + r.created_at AS created_at, + p.name AS place_name, + (r.created_at AT TIME ZONE 'Asia/Seoul') AS local_ts, + nullif(split_part(p.address, ' ', 2), '') AS district + FROM core.record r + JOIN core.place p ON p.id = r.place_id + WHERE r.member_id = :memberId + AND r.deleted_at IS NULL + ) + """; + + private static final String TOTALS_SQL = MY_RECORDS + """ + SELECT count(*) AS place_count, + count(DISTINCT district) AS district_count, + min(local_ts)::date AS first_recorded_on, + count(DISTINCT date_trunc('month', local_ts)) AS recorded_month_count + FROM my + """; + + /** 기록이 있는 달만 준다. 빈 달 채우기는 서비스가 한다 — SQL로 하면 이번 달을 DB 시계가 정한다. */ + private static final String MONTHS_SQL = MY_RECORDS + """ + SELECT to_char(date_trunc('month', local_ts), 'YYYY-MM') AS month, + count(*) AS record_count + FROM my + GROUP BY 1 + ORDER BY 1 + """; + + /** + * 동점을 {@code COLLATE "C"}로 끊는다. 한글 이름을 DB 기본 collation으로 정렬하면 운영 DB와 + * 테스트 컨테이너의 locale이 달라 순서가 갈린다 — S15P11A705-388이 같은 함정을 만나 + * 이름 정렬 자체를 피했다. 여기서는 이름 말고 끊을 키가 없으므로 collation을 명시해 고정한다. + */ + private static final String AREAS_SQL = MY_RECORDS + """ + SELECT district, count(*) AS record_count + FROM my + WHERE district IS NOT NULL + GROUP BY district + ORDER BY count(*) DESC, district COLLATE "C" ASC + LIMIT 5 + """; + + /** 동점이면 더 최근 날짜를 고른다 — 지난 기록보다 최근 기록이 화면에서 더 말이 된다. */ + private static final String BUSIEST_DAY_SQL = MY_RECORDS + """ + SELECT local_ts::date AS day, count(*) AS record_count + FROM my + GROUP BY 1 + ORDER BY count(*) DESC, 1 DESC + LIMIT 1 + """; + + /** + * 처음·마지막 기록의 장소 이름을 한 번에 가져온다. + * + *

{@code id}가 동률을 끊는다. {@code created_at}은 DB {@code now()}가 채우므로 한 + * 트랜잭션에서 만들어진 Record들이 같은 값을 가질 수 있고, 그때 이름이 회차마다 갈린다. + */ + private static final String FIRST_LAST_PLACE_SQL = MY_RECORDS + """ + SELECT + (SELECT place_name FROM my ORDER BY created_at ASC, record_id ASC LIMIT 1) AS first_place_name, + (SELECT place_name FROM my ORDER BY created_at DESC, record_id DESC LIMIT 1) AS last_place_name + """; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public MemberActivityRepository(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Totals findTotals(Long memberId) { + return jdbcTemplate.queryForObject(TOTALS_SQL, params(memberId), (rs, rowNum) -> { + java.sql.Date firstOn = rs.getDate("first_recorded_on"); + return new Totals( + rs.getLong("place_count"), + rs.getLong("district_count"), + firstOn == null ? null : firstOn.toLocalDate(), + rs.getLong("recorded_month_count")); + }); + } + + /** 기록이 있는 달만, 오래된 순. */ + public List findRecordedMonths(Long memberId) { + return jdbcTemplate.query(MONTHS_SQL, params(memberId), (rs, rowNum) -> + new MonthCount(rs.getString("month"), rs.getLong("record_count"))); + } + + public List findTopAreas(Long memberId) { + return jdbcTemplate.query(AREAS_SQL, params(memberId), (rs, rowNum) -> + new AreaCount(rs.getString("district"), rs.getLong("record_count"))); + } + + public Optional findBusiestDay(Long memberId) { + return jdbcTemplate.query(BUSIEST_DAY_SQL, params(memberId), (rs, rowNum) -> + new BusiestDay(rs.getDate("day").toLocalDate(), rs.getLong("record_count"))) + .stream() + .findFirst(); + } + + public FirstLastPlace findFirstAndLastPlace(Long memberId) { + return jdbcTemplate.queryForObject(FIRST_LAST_PLACE_SQL, params(memberId), (rs, rowNum) -> + new FirstLastPlace(rs.getString("first_place_name"), rs.getString("last_place_name"))); + } + + private MapSqlParameterSource params(Long memberId) { + return new MapSqlParameterSource(Map.of("memberId", memberId)); + } + + /** + * {@code TOTALS_SQL} 한 번의 결과. 응답 DTO가 아니라 쿼리 결과라서 별도 타입으로 둔다 — + * {@code recordedMonthCount}는 응답에서 {@code counts} 쪽으로 갈라져 담긴다. + */ + public record Totals(long placeCount, long districtCount, LocalDate firstRecordedOn, + long recordedMonthCount) { + } + + /** 둘 다 기록이 없으면 {@code null}이다. */ + public record FirstLastPlace(String firstPlaceName, String lastPlaceName) { + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberActivityService.java b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberActivityService.java new file mode 100644 index 00000000..7afde6f6 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/member/service/MemberActivityService.java @@ -0,0 +1,99 @@ +package com.pinlog.pinlogback.domain.member.service; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.pinlog.pinlogback.domain.collection.repository.CollectionRepository; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.Counts; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.Highlights; +import com.pinlog.pinlogback.domain.member.dto.MeActivityResponse.MonthCount; +import com.pinlog.pinlogback.domain.member.repository.MemberActivityRepository; +import com.pinlog.pinlogback.domain.member.repository.MemberActivityRepository.FirstLastPlace; +import com.pinlog.pinlogback.domain.record.repository.ContextRepository; + +/** + * 나의 활동 기록 집계(S15P11A705-397). 화면 진입 시 1회 호출된다. + * + *

집계를 한 쿼리로 묶지 않는다. 서로 다른 테이블의 독립 집계라 조인이 카운트를 곱하기 + * 때문이며, 이는 {@link MemberSummaryService}가 같은 이유로 이미 택한 방식이다. 진입당 1회 + * 호출되는 경로에서 인덱스를 타는 집계 여섯은 그 복잡도를 살 이유가 되지 않는다. + * + *

빈 달 채우기를 DB가 아니라 여기서 한다. "이번 달"이 구간의 끝인데, 그것을 SQL에서 + * 정하면 DB 서버의 시계와 타임존이 응답을 좌우한다. 애플리케이션이 KST로 정해서 채우면 + * 기준이 한 곳에 남는다. + */ +@Service +public class MemberActivityService { + + /** 날짜·월 경계의 기준. 한국 서비스이므로 사용자의 하루는 KST의 하루다. */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final MemberActivityRepository activityRepository; + private final ContextRepository contextRepository; + private final CollectionRepository collectionRepository; + + public MemberActivityService( + MemberActivityRepository activityRepository, + ContextRepository contextRepository, + CollectionRepository collectionRepository + ) { + this.activityRepository = activityRepository; + this.contextRepository = contextRepository; + this.collectionRepository = collectionRepository; + } + + @Transactional(readOnly = true) + public MeActivityResponse summarize(Long memberId) { + MemberActivityRepository.Totals totals = activityRepository.findTotals(memberId); + FirstLastPlace places = activityRepository.findFirstAndLastPlace(memberId); + + return new MeActivityResponse( + new MeActivityResponse.Totals( + totals.placeCount(), totals.districtCount(), totals.firstRecordedOn()), + fillGaps(activityRepository.findRecordedMonths(memberId), totals.firstRecordedOn()), + activityRepository.findTopAreas(memberId), + new Counts( + contextRepository.countByMemberId(memberId), + collectionRepository.countByMemberId(memberId), + totals.recordedMonthCount()), + new Highlights( + places.firstPlaceName(), + places.lastPlaceName(), + activityRepository.findBusiestDay(memberId).orElse(null))); + } + + /** + * 첫 기록이 있는 달부터 이번 달까지 빠짐없이 잇고, 기록이 없는 달을 0으로 채운다. + * + *

막대그래프가 x축을 건너뛰면 "그 달에 안 다녔다"가 아니라 "그 달이 없다"로 읽힌다 — + * 3월과 5월만 있는 그래프에서 4월이 사라지면 두 막대가 붙어 보인다. + * + * @param firstRecordedOn {@code null}이면 기록이 하나도 없다는 뜻이라 빈 목록을 준다 + */ + private List fillGaps(List recorded, LocalDate firstRecordedOn) { + if (firstRecordedOn == null) { + return List.of(); + } + Map byMonth = new LinkedHashMap<>(); + recorded.forEach(month -> byMonth.put(month.month(), month.recordCount())); + + List filled = new ArrayList<>(); + YearMonth last = YearMonth.now(KST); + for (YearMonth cursor = YearMonth.from(firstRecordedOn); + !cursor.isAfter(last); + cursor = cursor.plusMonths(1)) { + String key = cursor.toString(); + filled.add(new MonthCount(key, byMonth.getOrDefault(key, 0L))); + } + return filled; + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/record/repository/ContextRepository.java b/src/main/java/com/pinlog/pinlogback/domain/record/repository/ContextRepository.java index 48f01aef..36aeb4a5 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/record/repository/ContextRepository.java +++ b/src/main/java/com/pinlog/pinlogback/domain/record/repository/ContextRepository.java @@ -30,4 +30,16 @@ public interface ContextRepository extends JpaRepository { List findByRecordId(Long recordId); long countByRecordId(Long recordId); + + /** + * 나의 활동 기록 집계의 맥락 메모 수(S15P11A705-397). + * + *

{@code member_id}는 Context에 비정규화돼 있어 Record 조인 없이 센다(V3:52). 소프트 삭제는 + * {@code @SQLRestriction("deleted_at IS NULL")}이 count 쿼리에도 걸리므로 조건을 적지 않는다 — + * 명시하려다 빠뜨리면 오히려 삭제분이 섞인다(S15P11A705-200에서 뮤테이션으로 확인). + * + *

수정으로 교체된 구 Context는 소프트 삭제되므로 세지 않는다. 즉 이 값은 "지금 살아 있는 + * 메모 수"이지 "지금까지 쓴 메모 수"가 아니다. + */ + long countByMemberId(Long memberId); } diff --git a/src/test/java/com/pinlog/pinlogback/domain/member/MeActivityApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/member/MeActivityApiTests.java new file mode 100644 index 00000000..028198c5 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/member/MeActivityApiTests.java @@ -0,0 +1,309 @@ +package com.pinlog.pinlogback.domain.member; + +import static com.pinlog.pinlogback.support.AuthTestSupport.loginAs; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; + +import com.pinlog.pinlogback.support.CoreApiFixtures; + +import tools.jackson.databind.JsonNode; + +/** + * 나의 활동 기록 집계(S15P11A705-397). 기간은 전체 누적이며 기간 파라미터가 없다. + * + *

이 테스트가 고정해야 하는 것은 시간대다. {@code record.created_at}은 + * {@code TIMESTAMPTZ}라 {@code date_trunc}를 그냥 쓰면 서버 타임존을 탄다. 한국 서비스에서 + * "5월 17일에 3곳"은 KST 기준이어야 하므로, 경계에 걸친 시각(KST 자정 직후 = UTC 전날 오후)을 + * 일부러 넣어 UTC로 끊으면 틀리는 자리를 만든다. + * + *

{@code created_at}은 DB {@code now()}가 채우므로 API로는 과거를 만들 수 없다. 만든 뒤 + * {@code UPDATE}로 옮긴다 — {@code place.address}도 같은 이유다({@link CoreApiFixtures}의 + * {@code createRecord}가 주소를 "주소"로 고정한다). + */ +@SpringBootTest +@AutoConfigureMockMvc +@DisplayName("나의 활동 기록 집계") +class MeActivityApiTests extends CoreApiFixtures { + + private static final String PATH = "/v1/me/activity"; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Test + @DisplayName("다섯 덩어리를 한 응답으로 반환한다") + void returnsFiveBlocks() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-1", "성수 앤트러사이트", "서울 성동구 성수이로 87", "2026-01-14T10:00"); + + JsonNode data = getActivity(memberId).at("/data"); + + assertThat(data.propertyNames()) + .containsExactlyInAnyOrder("totals", "months", "areas", "counts", "highlights"); + } + + @Test + @DisplayName("totals가 장소 수·자치구 수·첫 기록일을 담는다") + void totalsCountPlacesAndDistricts() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-t1", "연남 커피리브레", "서울 마포구 성미산로 198", "2026-03-02T10:00"); + givenRecord(memberId, "act-t2", "망원 한강공원", "서울 마포구 마포나루길 467", "2026-04-05T10:00"); + givenRecord(memberId, "act-t3", "서촌 대오서점", "서울 종로구 자하문로 7길", "2026-02-11T10:00"); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totals.placeCount").value(3)) + // 마포구·종로구 둘. 같은 구의 장소 둘은 하나로 센다. + .andExpect(jsonPath("$.data.totals.districtCount").value(2)) + .andExpect(jsonPath("$.data.totals.firstRecordedOn").value("2026-02-11")); + } + + @Test + @DisplayName("months가 첫 기록 달부터 이번 달까지 이어지고 빈 달은 0으로 채워진다") + void monthsAreContiguousAndZeroFilled() throws Exception { + long memberId = newMemberId(); + // 3월과 5월에만 기록한다 — 4월이 0으로 채워져야 한다. + givenRecord(memberId, "act-m1", "장소A", "서울 마포구 A로 1", "2026-03-10T10:00"); + givenRecord(memberId, "act-m2", "장소B", "서울 마포구 B로 2", "2026-05-20T10:00"); + givenRecord(memberId, "act-m3", "장소C", "서울 마포구 C로 3", "2026-05-21T10:00"); + + JsonNode months = getActivity(memberId).at("/data/months"); + + assertThat(months.get(0).get("month").asText()).isEqualTo("2026-03"); + assertThat(months.get(0).get("recordCount").asInt()).isEqualTo(1); + assertThat(months.get(1).get("month").asText()).isEqualTo("2026-04"); + assertThat(months.get(1).get("recordCount").asInt()).isZero(); + assertThat(months.get(2).get("month").asText()).isEqualTo("2026-05"); + assertThat(months.get(2).get("recordCount").asInt()).isEqualTo(2); + + // 마지막 칸은 항상 이번 달이다 — 기록이 없어도 이어진다. + String thisMonth = YearMonth.now(KST).toString(); + assertThat(months.get(months.size() - 1).get("month").asText()).isEqualTo(thisMonth); + // 3월부터 이번 달까지 빠짐없이 이어진다. + assertThat(months.size()) + .isEqualTo((int) YearMonth.parse("2026-03").until(YearMonth.now(KST), ChronoUnit.MONTHS) + 1); + } + + @Test + @DisplayName("areas가 건수 내림차순 상위 5개이고 동점은 지역명 오름차순이다") + void areasAreTopFiveByCountThenName() throws Exception { + long memberId = newMemberId(); + // 마포 3 · 성동 2 · 그 밖에 1씩 다섯 구 → 상위 5개만 남고, 1건짜리는 이름순으로 끊긴다. + givenRecord(memberId, "act-a1", "A", "서울 마포구 1로 1", "2026-03-01T10:00"); + givenRecord(memberId, "act-a2", "B", "서울 마포구 2로 2", "2026-03-02T10:00"); + givenRecord(memberId, "act-a3", "C", "서울 마포구 3로 3", "2026-03-03T10:00"); + givenRecord(memberId, "act-a4", "D", "서울 성동구 1로 1", "2026-03-04T10:00"); + givenRecord(memberId, "act-a5", "E", "서울 성동구 2로 2", "2026-03-05T10:00"); + givenRecord(memberId, "act-a6", "F", "서울 종로구 1로 1", "2026-03-06T10:00"); + givenRecord(memberId, "act-a7", "G", "서울 중구 1로 1", "2026-03-07T10:00"); + givenRecord(memberId, "act-a8", "H", "서울 강남구 1로 1", "2026-03-08T10:00"); + givenRecord(memberId, "act-a9", "I", "서울 용산구 1로 1", "2026-03-09T10:00"); + + JsonNode areas = getActivity(memberId).at("/data/areas"); + + assertThat(areas).hasSize(5); + assertThat(areas.get(0).get("district").asText()).isEqualTo("마포구"); + assertThat(areas.get(0).get("recordCount").asInt()).isEqualTo(3); + assertThat(areas.get(1).get("district").asText()).isEqualTo("성동구"); + assertThat(areas.get(1).get("recordCount").asInt()).isEqualTo(2); + // 1건 동점 넷 중 이름 오름차순으로 앞선 셋만 남는다: 강남구 < 용산구 < 종로구 < 중구 + assertThat(List.of( + areas.get(2).get("district").asText(), + areas.get(3).get("district").asText(), + areas.get(4).get("district").asText())) + .containsExactly("강남구", "용산구", "종로구"); + } + + @Test + @DisplayName("counts가 맥락 메모·컬렉션·기록한 달 수를 담는다") + void countsCoverContextsCollectionsAndRecordedMonths() throws Exception { + long memberId = newMemberId(); + long r1 = givenRecord(memberId, "act-c1", "A", "서울 마포구 1로 1", "2026-03-01T10:00"); + long r2 = givenRecord(memberId, "act-c2", "B", "서울 마포구 2로 2", "2026-03-02T10:00"); + givenRecord(memberId, "act-c3", "C", "서울 마포구 3로 3", "2026-05-02T10:00"); + createCollection(memberId, "책장 하나", List.of(r1, r2)); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.contextCount").value(3)) + .andExpect(jsonPath("$.data.counts.collectionCount").value(1)) + // 3월과 5월 — 기록이 실제로 있는 달만 센다(months 길이와 다르다) + .andExpect(jsonPath("$.data.counts.recordedMonthCount").value(2)); + } + + @Test + @DisplayName("highlights가 처음 기록한 곳·가장 붐빈 하루·가장 최근 기록을 담는다") + void highlightsCoverFirstBusiestAndLast() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-h1", "성수 앤트러사이트", "서울 성동구 성수이로 87", "2026-01-14T09:00"); + givenRecord(memberId, "act-h2", "B", "서울 마포구 2로 2", "2026-05-17T09:00"); + givenRecord(memberId, "act-h3", "C", "서울 마포구 3로 3", "2026-05-17T13:00"); + givenRecord(memberId, "act-h4", "D", "서울 마포구 4로 4", "2026-05-17T20:00"); + givenRecord(memberId, "act-h5", "연남 커피리브레", "서울 마포구 성미산로 198", "2026-06-01T09:00"); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.highlights.firstPlaceName").value("성수 앤트러사이트")) + .andExpect(jsonPath("$.data.highlights.busiestDay.date").value("2026-05-17")) + .andExpect(jsonPath("$.data.highlights.busiestDay.recordCount").value(3)) + .andExpect(jsonPath("$.data.highlights.lastPlaceName").value("연남 커피리브레")); + } + + /** + * 경계 검증. KST 2026-05-18 00:30은 UTC로 2026-05-17 15:30이다 — UTC로 끊으면 17일에 붙어 + * 17일이 3건이 되고, KST로 끊어야 18일에 붙어 17일이 2건으로 남는다. 같은 이유로 이 기록은 + * 5월에 속한다(월 경계는 이 자리에서 갈리지 않지만 같은 표현식을 공유한다). + */ + @Test + @DisplayName("하루 경계를 KST로 끊는다 — UTC로 끊으면 전날에 붙는 시각") + void dayBoundaryFollowsSeoulTime() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-tz1", "A", "서울 마포구 1로 1", "2026-05-17T09:00"); + givenRecord(memberId, "act-tz2", "B", "서울 마포구 2로 2", "2026-05-17T21:00"); + // KST 18일 00:30 — UTC로는 17일 15:30이다 + givenRecord(memberId, "act-tz3", "C", "서울 마포구 3로 3", "2026-05-18T00:30"); + // 18일을 이기지 못하도록 17일에 하나 더 두지 않는다. 17일 2건 vs 18일 1건이라 17일이 최다다. + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.highlights.busiestDay.date").value("2026-05-17")) + .andExpect(jsonPath("$.data.highlights.busiestDay.recordCount").value(2)); + } + + @Test + @DisplayName("가장 붐빈 하루가 동점이면 더 최근 날짜를 고른다") + void busiestDayTieBreaksToMoreRecent() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-b1", "A", "서울 마포구 1로 1", "2026-03-10T09:00"); + givenRecord(memberId, "act-b2", "B", "서울 마포구 2로 2", "2026-03-10T11:00"); + givenRecord(memberId, "act-b3", "C", "서울 마포구 3로 3", "2026-04-20T09:00"); + givenRecord(memberId, "act-b4", "D", "서울 마포구 4로 4", "2026-04-20T11:00"); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.highlights.busiestDay.date").value("2026-04-20")) + .andExpect(jsonPath("$.data.highlights.busiestDay.recordCount").value(2)); + } + + @Test + @DisplayName("소프트 삭제된 Record와 남의 Record는 모든 집계에서 빠진다") + void softDeletedAndOthersRecordsAreExcluded() throws Exception { + long memberId = newMemberId(); + long live = givenRecord(memberId, "act-x1", "살아남을 곳", "서울 마포구 1로 1", "2026-03-01T10:00"); + long dead = givenRecord(memberId, "act-x2", "지워질 곳", "서울 종로구 1로 1", "2026-03-02T10:00"); + long otherId = newMemberId(); + givenRecord(otherId, "act-x3", "남의 곳", "서울 중구 1로 1", "2026-03-03T10:00"); + + softDelete("core.record", dead); + + JsonNode data = getActivity(memberId).at("/data"); + + assertThat(data.at("/totals/placeCount").asInt()).isEqualTo(1); + assertThat(data.at("/totals/districtCount").asInt()).isEqualTo(1); + assertThat(data.at("/areas").size()).isEqualTo(1); + assertThat(data.at("/areas/0/district").asText()).isEqualTo("마포구"); + assertThat(data.at("/highlights/firstPlaceName").asText()).isEqualTo("살아남을 곳"); + // 지우지 않은 쪽이 남아 있는 것까지 확인한다 — 전부 0이라 통과하는 것을 막는다. + assertThat(deletedAtOf("core.record", live)).isNull(); + } + + @Test + @DisplayName("소프트 삭제된 Context·Collection은 counts에서 빠진다") + void softDeletedContextsAndCollectionsAreExcluded() throws Exception { + long memberId = newMemberId(); + long r1 = givenRecord(memberId, "act-s1", "A", "서울 마포구 1로 1", "2026-03-01T10:00"); + long r2 = givenRecord(memberId, "act-s2", "B", "서울 마포구 2로 2", "2026-03-02T10:00"); + long keptCollection = createCollection(memberId, "남을 책장", List.of(r1)); + long goneCollection = createCollection(memberId, "지울 책장", List.of(r2)); + + softDelete("core.context", firstContextId(memberId, r2)); + softDelete("core.collection", goneCollection); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.contextCount").value(1)) + .andExpect(jsonPath("$.data.counts.collectionCount").value(1)); + + assertThat(deletedAtOf("core.collection", keptCollection)).isNull(); + } + + @Test + @DisplayName("기록이 없는 계정은 빈 배열과 0으로 채워진 200을 받는다") + void freshMemberGetsEmptyAggregates() throws Exception { + long memberId = newMemberId(); + + mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totals.placeCount").value(0)) + .andExpect(jsonPath("$.data.totals.districtCount").value(0)) + .andExpect(jsonPath("$.data.totals.firstRecordedOn").doesNotExist()) + .andExpect(jsonPath("$.data.months").isEmpty()) + .andExpect(jsonPath("$.data.areas").isEmpty()) + .andExpect(jsonPath("$.data.counts.contextCount").value(0)) + .andExpect(jsonPath("$.data.counts.collectionCount").value(0)) + .andExpect(jsonPath("$.data.counts.recordedMonthCount").value(0)) + .andExpect(jsonPath("$.data.highlights.firstPlaceName").doesNotExist()) + .andExpect(jsonPath("$.data.highlights.busiestDay").doesNotExist()) + .andExpect(jsonPath("$.data.highlights.lastPlaceName").doesNotExist()); + } + + @Test + @DisplayName("memberId를 반환하지 않는다") + void doesNotExposeMemberId() throws Exception { + long memberId = newMemberId(); + givenRecord(memberId, "act-id1", "장소이름", "서울 마포구 1로 1", "2026-03-01T10:00"); + + String body = mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("memberId"); + } + + @Test + @DisplayName("미인증 요청은 401이다") + void unauthenticatedRequestIs401() throws Exception { + mockMvc.perform(get(PATH).with(csrf())) + .andExpect(status().isUnauthorized()); + } + + // ---------- 로컬 픽스처 ---------- + + /** + * 장소 이름·주소와 작성 시각을 지정해 Record를 만든다. + * + *

{@link CoreApiFixtures#createRecord}는 주소를 "주소"로, 시각을 DB {@code now()}로 고정한다. + * 이 집계는 둘 다 입력이므로 만든 뒤 {@code UPDATE}로 옮긴다 — 공유 픽스처를 고치면 다른 + * 진행 중인 브랜치의 테스트까지 건드리게 되어 여기 지역 헬퍼로 둔다. + * + * @param createdAtKst KST 기준 벽시계 시각(예: {@code "2026-05-18T00:30"}) + */ + private long givenRecord(long memberId, String kakaoPlaceId, String placeName, String address, + String createdAtKst) throws Exception { + long recordId = createRecord(memberId, kakaoPlaceId, "맥락 " + kakaoPlaceId); + jdbcTemplate.update("UPDATE core.place SET name = ?, address = ? WHERE kakao_place_id = ?", + placeName, address, kakaoPlaceId); + jdbcTemplate.update("UPDATE core.record SET created_at = ? WHERE id = ?", + Timestamp.from(LocalDateTime.parse(createdAtKst).atZone(KST).toInstant()), recordId); + return recordId; + } + + private JsonNode getActivity(long memberId) throws Exception { + return parse(mockMvc.perform(get(PATH).with(loginAs(memberId))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + } +}