Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,13 @@ public ResponseEntity<ApiResponse<ShareResponse>> create(
@ApiDocs.ListQuery
@Operation(summary = "내 공유 목록 조회",
description = "내 공유를 최신순으로 페이지 조회합니다. 상태 필터는 생략할 수 있고, "
+ "결과가 없으면 200과 빈 items를 반환합니다. share_url 은 저장된 것이 없어 "
+ "결과가 없으면 200과 빈 items를 반환합니다. ACTIVE 필터는 만료 시각이 "
+ "지나지 않은 것만 돌려줍니다. share_url 은 저장된 것이 없어 "
+ "생성 응답으로만 내려간다.")
public ApiResponse<PageResponse<ShareSummaryResponse>> list(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "상태 필터(ACTIVE/REVOKED). 생략 시 전체")
@Parameter(description = "상태 필터(ACTIVE/REVOKED). 생략 시 전체. "
+ "ACTIVE 는 만료되지 않은 것만 포함한다")
@RequestParam(required = false) ShareStatus status,
@Parameter(description = "0부터 시작하는 페이지 번호. 음수면 400 INVALID_REQUEST")
@RequestParam(defaultValue = "0") int page,
Expand Down
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
Expand Up @@ -32,6 +32,10 @@ interface ShareJpaRepository extends JpaRepository<ShareEntity, UUID> {

Page<ShareEntity> findByUserIdAndStatus(UUID userId, String status, Pageable pageable);

/** ACTIVE 필터 전용. 상태가 ACTIVE 라도 만료 시각이 지났으면 더 이상 열리지 않는다. */
Page<ShareEntity> findByUserIdAndStatusAndExpiresAtAfter(
UUID userId, String status, Instant now, Pageable pageable);

void deleteAllByUserId(UUID userId);

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,9 @@ public Optional<Share> findByTokenHash(String tokenHash) {
}

@Override
public SharePage findPage(UUID userId, ShareStatus status, int page, int size) {
Page<ShareEntity> result = status == null
? shareRepository.findByUserId(userId, pageRequest(page, size))
: shareRepository.findByUserIdAndStatus(userId, status.name(),
pageRequest(page, size));
public SharePage findPage(
UUID userId, ShareStatus status, int page, int size, Instant now) {
Page<ShareEntity> result = findEntityPage(userId, status, pageRequest(page, size), now);
return new SharePage(
result.getContent().stream().map(ShareEntity::toDomain).toList(),
result.getTotalElements());
Expand All @@ -86,6 +84,22 @@ public Set<UUID> findSharedRecordIds(UUID ownerId, Collection<UUID> recordIds, I
ownerId, recordIds, ShareStatus.ACTIVE.name(), now);
}

/**
* ACTIVE 만 만료 조건이 붙는다. REVOKED 는 철회 시각이 이미 상태로 굳었고, 전체 조회는
* 만료된 링크까지 보여 주는 것이 목록의 목적이라 그대로 둔다.
*/
private Page<ShareEntity> findEntityPage(
UUID userId, ShareStatus status, PageRequest pageRequest, Instant now) {
if (status == null) {
return shareRepository.findByUserId(userId, pageRequest);
}
if (status == ShareStatus.ACTIVE) {
return shareRepository.findByUserIdAndStatusAndExpiresAtAfter(
userId, status.name(), now, pageRequest);
}
return shareRepository.findByUserIdAndStatus(userId, status.name(), pageRequest);
}

private PageRequest pageRequest(int page, int size) {
int boundedSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
return PageRequest.of(Math.max(page, 0), boundedSize, Sort.by(
Expand Down
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
Expand Up @@ -82,7 +82,7 @@ public ListSharesUseCase.Result list(ListSharesUseCase.Query query) {
throw new ApplicationException(ErrorCode.INVALID_REQUEST);
}
SharePage page = shareRepositoryPort.findPage(
query.requesterId(), query.status(), query.page(), query.size());
query.requesterId(), query.status(), query.page(), query.size(), Instant.now());
return new ListSharesUseCase.Result(
page.items(), query.page(), query.size(), page.totalElements());
}
Expand Down
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.heddy.domain.sharing.model.SharePage;
import com.heddy.domain.sharing.model.ShareStatus;

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

Expand All @@ -30,8 +31,14 @@ public interface ShareRepositoryPort {
*/
void detachSavedStyle(UUID savedStyleId);

/** 소유자의 공유를 최신순으로 페이지 조회한다. 상태 필터는 생략 가능하다. */
SharePage findPage(UUID userId, ShareStatus status, int page, int size);
/**
* 소유자의 공유를 최신순으로 페이지 조회한다. 상태 필터는 생략 가능하다.
*
* <p>ACTIVE 필터는 {@code expiresAt > now} 까지 함께 본다. 만료는 상태가 아니라 시각
* 비교로 판정하는 것이 이 도메인의 설계라(V19 주석) 상태 컬럼만 보면 이미 죽은 링크가
* 활성으로 잡힌다.
*/
SharePage findPage(UUID userId, ShareStatus status, int page, int size, Instant now);

/** 회원 탈퇴 시 공개 링크와 연결 행을 함께 제거한다. */
void deleteAllByUserId(UUID userId);
Expand Down
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);
}
Loading
Loading