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
@@ -0,0 +1,46 @@
package com.heddy.adapter.in.web.summary.controller;

import com.heddy.adapter.in.web.summary.dto.MySummaryResponse;
import com.heddy.domain.summary.port.in.GetMySummaryUseCase;
import com.heddy.global.docs.ApiDocs;
import com.heddy.global.filter.RequestIdFilter;
import com.heddy.global.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

@RestController
@RequestMapping("/me/summary")
@RequiredArgsConstructor
@Tag(name = "홈 요약", description = "홈 화면 상단 요약 타일의 카운트")
@SecurityRequirement(name = "bearerAuth")
public class SummaryController {

private final GetMySummaryUseCase getMySummaryUseCase;

@GetMapping
@ApiDocs.Ok
@ApiDocs.Authenticated
@Operation(summary = "홈 요약 카운트 조회",
description = "시술 기록·분석 완료 기록·후보 스타일·공유 중 기록 수를 한 번에 "
+ "돌려줍니다. shared_record_count 는 공유 링크 수가 아니라 살아있는 "
+ "공유에 포함된 시술 기록 수입니다 — 링크 하나가 기록 여러 개를 담을 수 "
+ "있고 같은 기록을 여러 링크로 공유할 수도 있어 두 숫자는 단위가 다릅니다. "
+ "자료가 없으면 0 입니다.")
public ApiResponse<MySummaryResponse> getSummary(
@AuthenticationPrincipal UUID userId,
HttpServletRequest servletRequest
) {
return ApiResponse.success(
MySummaryResponse.from(getMySummaryUseCase.get(userId)),
RequestIdFilter.get(servletRequest));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.heddy.adapter.in.web.summary.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.heddy.domain.summary.model.MySummary;
import io.swagger.v3.oas.annotations.media.Schema;

public record MySummaryResponse(
@Schema(description = "내 시술 기록 수")
@JsonProperty("treatment_record_count") long treatmentRecordCount,

@Schema(description = "분석이 성공한 시술 기록 수")
@JsonProperty("analyzed_record_count") long analyzedRecordCount,

@Schema(description = "저장한 후보 스타일 수")
@JsonProperty("saved_style_count") long savedStyleCount,

@Schema(description = "살아있는 공유에 포함된 시술 기록 수. 공유 링크 수가 아니다")
@JsonProperty("shared_record_count") long sharedRecordCount
) {

public static MySummaryResponse from(MySummary summary) {
return new MySummaryResponse(
summary.treatmentRecordCount(), summary.analyzedRecordCount(),
summary.savedStyleCount(), summary.sharedRecordCount());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.heddy.adapter.out.persistence.summary;

import com.heddy.domain.sharing.model.ShareStatus;
import com.heddy.domain.summary.model.MySummary;
import com.heddy.domain.summary.port.out.MySummaryQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

import java.sql.Timestamp;
import java.time.Instant;
import java.util.UUID;

/**
* 카운트 4종을 스칼라 서브쿼리 하나로 묶어 왕복 한 번에 끝낸다. 도메인별 리포지토리를 네 번
* 호출하면 화면 하나에 질의가 네 번 나가고, 정의도 네 곳에 흩어진다.
*
* <p>타 도메인 테이블을 직접 읽는 읽기 전용 어댑터다 — 공개 조회의
* {@code SharedContentJdbcAdapter} 와 같은 이유로, 집계만 하는 질의가 다른 도메인의
* 영속성 내부를 재사용할 이유가 없다(타 도메인 리포지토리는 패키지 비공개다).
*/
@Component
@RequiredArgsConstructor
public class MySummaryJdbcAdapter implements MySummaryQueryPort {

private final JdbcTemplate jdbcTemplate;

@Override
public MySummary count(UUID userId, Instant now) {
// 분석·공유는 record 기준 distinct 다. 한 기록에 성공 분석이 여러 건일 수 있고,
// 한 기록이 여러 공유 링크에 들어갈 수도 있어 행을 세면 기록 수보다 커진다.
return jdbcTemplate.queryForObject("""
SELECT
(SELECT count(*) FROM treatment_records WHERE user_id = ?)
AS treatment_record_count,
(SELECT count(DISTINCT record_id) FROM analysis_results WHERE user_id = ?)
AS analyzed_record_count,
(SELECT count(*) FROM saved_styles WHERE user_id = ?)
AS saved_style_count,
(SELECT count(DISTINCT sr.record_id)
FROM share_records sr
JOIN shares s ON s.share_id = sr.share_id
WHERE s.user_id = ? AND s.status = ? AND s.expires_at > ?)
AS shared_record_count
""",
(rs, rowNum) -> new MySummary(
rs.getLong("treatment_record_count"),
rs.getLong("analyzed_record_count"),
rs.getLong("saved_style_count"),
rs.getLong("shared_record_count")),
userId, userId, userId, userId, ShareStatus.ACTIVE.name(), Timestamp.from(now));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.heddy.application.summary.service;

import com.heddy.domain.summary.model.MySummary;
import com.heddy.domain.summary.port.in.GetMySummaryUseCase;
import com.heddy.domain.summary.port.out.MySummaryQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.UUID;

/**
* 홈 요약 카운트. 집계 규칙을 서버 한 곳에 둬 화면마다 정의가 갈리는 것을 막는다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SummaryService implements GetMySummaryUseCase {

private final MySummaryQueryPort mySummaryQueryPort;

@Override
public MySummary get(UUID requesterId) {
return mySummaryQueryPort.count(requesterId, Instant.now());
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/heddy/domain/summary/model/MySummary.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.heddy.domain.summary.model;

/**
* 홈 화면 상단 요약 타일 4종의 카운트.
*
* <p>네 값의 단위는 모두 "개수"지만 세는 대상이 다르다. {@code sharedRecordCount} 는 공유
* 링크가 아니라 <b>시술기록</b> 을 센다 — 링크 하나가 기록 여러 개를 담을 수도, 기록 없이
* 후보 스타일만 담을 수도 있고 같은 기록으로 링크를 여러 번 만들 수도 있어, 링크 수를 세면
* 시술기록 수와 비교 자체가 성립하지 않는다.
*/
public record MySummary(
long treatmentRecordCount,
long analyzedRecordCount,
long savedStyleCount,
long sharedRecordCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.heddy.domain.summary.port.in;

import com.heddy.domain.summary.model.MySummary;

import java.util.UUID;

public interface GetMySummaryUseCase {

/** 자료가 하나도 없으면 모두 0 이다. 빈 상태는 오류가 아니다. */
MySummary get(UUID requesterId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.heddy.domain.summary.port.out;

import com.heddy.domain.summary.model.MySummary;

import java.time.Instant;
import java.util.UUID;

public interface MySummaryQueryPort {

/**
* 카운트 4종을 한 번에 센다.
*
* @param now 공유의 만료 판정 기준 시각. 만료는 상태가 아니라 시각 비교라 호출부가 넘긴다.
*/
MySummary count(UUID userId, Instant now);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.heddy.adapter.in.web.summary;

import com.heddy.support.PostgresIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
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;

/**
* 홈 요약 카운트의 HTTP 계약. 화면이 이 값을 그대로 타일에 박으므로 필드 이름과 단위가
* 계약이다 — shared_record_count 는 공유 링크 수가 아니라 기록 수다.
*/
@Transactional
@AutoConfigureMockMvc
class SummaryApiIntegrationTest extends PostgresIntegrationTest {

private static final UUID USER_ID = UUID.fromString("85000000-0000-4000-8000-000000000001");

@Autowired MockMvc mockMvc;
@Autowired JdbcTemplate jdbcTemplate;

@BeforeEach
void setUpUser() {
insertUser(USER_ID);
}

@Test
void answersZerosForAnEmptyAccount() throws Exception {
mockMvc.perform(get("/me/summary")
.with(authentication(userAuthentication(USER_ID)))
.header("X-Request-Id", "request-161"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.treatment_record_count").value(0))
.andExpect(jsonPath("$.data.analyzed_record_count").value(0))
.andExpect(jsonPath("$.data.saved_style_count").value(0))
.andExpect(jsonPath("$.data.shared_record_count").value(0))
.andExpect(jsonPath("$.request_id").value("request-161"));
}

/** 기록 2건을 링크 3개로 공유해도 "공유 중" 은 2 다. 링크 수를 세면 3 이 나온다. */
@Test
void countsSharedRecordsRatherThanShareLinks() throws Exception {
UUID first = insertRecord();
UUID second = insertRecord();
insertRecord();
insertActiveShare(first);
insertActiveShare(first);
insertActiveShare(second);

mockMvc.perform(get("/me/summary")
.with(authentication(userAuthentication(USER_ID))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.treatment_record_count").value(3))
.andExpect(jsonPath("$.data.shared_record_count").value(2));
}

@Test
void rejectsAnAnonymousCall() throws Exception {
mockMvc.perform(get("/me/summary"))
.andExpect(status().isUnauthorized());
}

// ------------------------------------------------------------------ 헬퍼

private UsernamePasswordAuthenticationToken userAuthentication(UUID userId) {
return new UsernamePasswordAuthenticationToken(userId, null, List.of());
}

private void insertUser(UUID userId) {
jdbcTemplate.update("""
INSERT INTO users (
user_id, email, password_hash, auth_provider, status, login_fail_count
) VALUES (?, ?, ?, 'EMAIL', 'ACTIVE', 0)
""", userId, userId + "@example.com", "hash");
}

private UUID insertRecord() {
UUID recordId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO treatment_records (record_id, user_id, service_types, performed_at)
VALUES (?, ?, ?::jsonb, now())
""", recordId, USER_ID, "[\"CUT\"]");
return recordId;
}

private void insertActiveShare(UUID recordId) {
UUID shareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (share_id, user_id, token_hash, status, expires_at)
VALUES (?, ?, ?, 'ACTIVE', now() + interval '7 days')
""", shareId, USER_ID, UUID.randomUUID().toString().replace("-", ""));
jdbcTemplate.update(
"INSERT INTO share_records (share_id, record_id) VALUES (?, ?)",
shareId, recordId);
}
}
Loading
Loading