From ae0551bbc27cc6ac3200d90a55554537733dab9a Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 22 Oct 2026 01:18:09 +0900 Subject: [PATCH 01/11] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=83=9D=EC=84=B1=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=8B=A8=EC=9C=84=EB=B3=84=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 일/주/월 단위로 테이블 created_at, used_at 건수를 집계한다. Co-Authored-By: Claude Opus 5.5 --- .../admin/AdminStatsController.java | 34 ++++++ .../debatetimer/domain/stats/StatPeriod.java | 70 ++++++++++++ .../debatetimer/domain/stats/StatUnit.java | 33 ++++++ .../CustomizeTableDomainRepository.java | 11 ++ .../dto/admin/TableUsageStatResponse.java | 10 ++ .../dto/admin/TableUsageStatsResponse.java | 13 +++ .../exception/errorcode/ClientErrorCode.java | 3 + .../customize/CustomizeTableRepository.java | 8 ++ .../service/admin/TableUsageStatsService.java | 40 +++++++ .../admin/AdminStatsControllerTest.java | 54 +++++++++ .../domain/stats/StatPeriodTest.java | 103 ++++++++++++++++++ .../admin/TableUsageStatsServiceTest.java | 80 ++++++++++++++ 12 files changed, 459 insertions(+) create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminStatsController.java create mode 100644 src/main/java/com/debatetimer/domain/stats/StatPeriod.java create mode 100644 src/main/java/com/debatetimer/domain/stats/StatUnit.java create mode 100644 src/main/java/com/debatetimer/dto/admin/TableUsageStatResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/TableUsageStatsResponse.java create mode 100644 src/main/java/com/debatetimer/service/admin/TableUsageStatsService.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java create mode 100644 src/test/java/com/debatetimer/domain/stats/StatPeriodTest.java create mode 100644 src/test/java/com/debatetimer/service/admin/TableUsageStatsServiceTest.java diff --git a/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java new file mode 100644 index 00000000..8008fb9a --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java @@ -0,0 +1,34 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.dto.admin.TableUsageStatsResponse; +import com.debatetimer.service.admin.TableUsageStatsService; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AdminStatsController { + + private final AdminAuthorizer adminAuthorizer; + private final TableUsageStatsService tableUsageStatsService; + + @GetMapping("/api/admin/tables/stats") + @ResponseStatus(HttpStatus.OK) + public TableUsageStatsResponse getTableUsageStats( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @RequestParam("unit") StatUnit unit, + @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + adminAuthorizer.authorize(password); + return tableUsageStatsService.getStats(unit, from, to); + } +} diff --git a/src/main/java/com/debatetimer/domain/stats/StatPeriod.java b/src/main/java/com/debatetimer/domain/stats/StatPeriod.java new file mode 100644 index 00000000..cc178bd3 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/stats/StatPeriod.java @@ -0,0 +1,70 @@ +package com.debatetimer.domain.stats; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.Getter; + +/** + * 조회 기간을 집계 단위로 나눈 구간들. 시작일과 종료일이 걸친 구간 전체를 포함하도록 경계를 넓힌다. + */ +@Getter +public class StatPeriod { + + public static final int MAX_PERIOD_COUNT = 366; + + private final StatUnit unit; + private final LocalDate start; + private final LocalDate end; + + public StatPeriod(StatUnit unit, LocalDate from, LocalDate to) { + validate(unit, from, to); + this.unit = unit; + this.start = unit.truncate(from); + this.end = unit.next(unit.truncate(to)); + validatePeriodCount(); + } + + private void validate(StatUnit unit, LocalDate from, LocalDate to) { + if (unit == null || from == null || to == null || from.isAfter(to)) { + throw new DTClientErrorException(ClientErrorCode.INVALID_STATS_RANGE); + } + } + + private void validatePeriodCount() { + if (periodStarts().limit(MAX_PERIOD_COUNT + 1L).count() > MAX_PERIOD_COUNT) { + throw new DTClientErrorException(ClientErrorCode.INVALID_STATS_RANGE); + } + } + + public LocalDateTime getStartAt() { + return start.atStartOfDay(); + } + + public LocalDateTime getEndAt() { + return end.atStartOfDay(); + } + + public List getPeriodStarts() { + return periodStarts().toList(); + } + + private Stream periodStarts() { + return Stream.iterate(start, date -> date.isBefore(end), unit::next); + } + + public Map countByPeriod(List times) { + return times.stream() + .filter(this::contains) + .collect(Collectors.groupingBy(time -> unit.truncate(time.toLocalDate()), Collectors.counting())); + } + + private boolean contains(LocalDateTime time) { + return !time.isBefore(getStartAt()) && time.isBefore(getEndAt()); + } +} diff --git a/src/main/java/com/debatetimer/domain/stats/StatUnit.java b/src/main/java/com/debatetimer/domain/stats/StatUnit.java new file mode 100644 index 00000000..8f4a1fbe --- /dev/null +++ b/src/main/java/com/debatetimer/domain/stats/StatUnit.java @@ -0,0 +1,33 @@ +package com.debatetimer.domain.stats; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.temporal.TemporalAdjusters; +import java.util.function.UnaryOperator; + +/** + * 통계 집계 단위. 주간은 월요일, 월별은 1일을 구간의 시작일로 본다. + */ +public enum StatUnit { + + DAY(date -> date, date -> date.plusDays(1)), + WEEK(date -> date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)), date -> date.plusWeeks(1)), + MONTH(date -> date.withDayOfMonth(1), date -> date.plusMonths(1)), + ; + + private final UnaryOperator truncator; + private final UnaryOperator nextFinder; + + StatUnit(UnaryOperator truncator, UnaryOperator nextFinder) { + this.truncator = truncator; + this.nextFinder = nextFinder; + } + + public LocalDate truncate(LocalDate date) { + return truncator.apply(date); + } + + public LocalDate next(LocalDate periodStart) { + return nextFinder.apply(periodStart); + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java index cddd6a9b..ab1cbe04 100644 --- a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java @@ -9,6 +9,7 @@ import com.debatetimer.repository.customize.BellRepository; import com.debatetimer.repository.customize.CustomizeTableRepository; import com.debatetimer.repository.customize.CustomizeTimeBoxRepository; +import java.time.LocalDateTime; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -91,6 +92,16 @@ public int countTimeBoxes(long tableId) { return timeBoxRepository.countByCustomizeTableId(tableId); } + @Transactional(readOnly = true) + public List findCreatedAtBetween(LocalDateTime from, LocalDateTime to) { + return tableRepository.findCreatedAtBetween(from, to); + } + + @Transactional(readOnly = true) + public List findUsedAtBetween(LocalDateTime from, LocalDateTime to) { + return tableRepository.findUsedAtBetween(from, to); + } + private List toCustomizeTimeBoxes( List timeBoxEntities, List bellEntities diff --git a/src/main/java/com/debatetimer/dto/admin/TableUsageStatResponse.java b/src/main/java/com/debatetimer/dto/admin/TableUsageStatResponse.java new file mode 100644 index 00000000..41c38931 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/TableUsageStatResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.admin; + +import java.time.LocalDate; + +public record TableUsageStatResponse( + LocalDate periodStart, + long createdCount, + long usedCount +) { +} diff --git a/src/main/java/com/debatetimer/dto/admin/TableUsageStatsResponse.java b/src/main/java/com/debatetimer/dto/admin/TableUsageStatsResponse.java new file mode 100644 index 00000000..cfa2a3f2 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/TableUsageStatsResponse.java @@ -0,0 +1,13 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.stats.StatUnit; +import java.time.LocalDate; +import java.util.List; + +public record TableUsageStatsResponse( + StatUnit unit, + LocalDate from, + LocalDate to, + List periods +) { +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index 728bf014..edb125d8 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -4,6 +4,7 @@ import com.debatetimer.domain.customize.Bell; import com.debatetimer.domain.customize.TableName; import com.debatetimer.domain.customize.TeamName; +import com.debatetimer.domain.stats.StatPeriod; import com.debatetimer.entity.customize.CustomizeTimeBoxEntity; import com.debatetimer.service.admin.SharingStatsService; import lombok.Getter; @@ -89,6 +90,8 @@ public enum ClientErrorCode implements ResponseErrorCode { INVALID_ADMIN_PASSWORD(HttpStatus.UNAUTHORIZED, "어드민 비밀번호가 올바르지 않습니다"), INVALID_STATS_PERIOD(HttpStatus.BAD_REQUEST, "조회 기간은 시작일이 종료일보다 늦을 수 없고, 최대 %d일까지 조회할 수 있습니다" .formatted(SharingStatsService.MAX_PERIOD_DAYS)), + INVALID_STATS_RANGE(HttpStatus.BAD_REQUEST, "조회 기간은 시작일이 종료일보다 늦을 수 없고, 최대 %d개 구간까지 조회할 수 있습니다" + .formatted(StatPeriod.MAX_PERIOD_COUNT)), ; private final HttpStatus status; diff --git a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java index 5e320257..bc77b1f8 100644 --- a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java +++ b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java @@ -4,9 +4,11 @@ import com.debatetimer.entity.customize.CustomizeTableEntity; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; public interface CustomizeTableRepository extends Repository { @@ -38,5 +40,11 @@ default CustomizeTableEntity getWithMemberById(long tableId) { .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND)); } + @Query("SELECT t.createdAt FROM CustomizeTableEntity t WHERE t.createdAt >= :from AND t.createdAt < :to") + List findCreatedAtBetween(LocalDateTime from, LocalDateTime to); + + @Query("SELECT t.usedAt FROM CustomizeTableEntity t WHERE t.usedAt >= :from AND t.usedAt < :to") + List findUsedAtBetween(LocalDateTime from, LocalDateTime to); + void delete(CustomizeTableEntity table); } diff --git a/src/main/java/com/debatetimer/service/admin/TableUsageStatsService.java b/src/main/java/com/debatetimer/service/admin/TableUsageStatsService.java new file mode 100644 index 00000000..fbfb94ca --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/TableUsageStatsService.java @@ -0,0 +1,40 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.stats.StatPeriod; +import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.dto.admin.TableUsageStatResponse; +import com.debatetimer.dto.admin.TableUsageStatsResponse; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 테이블 생성 시각(created_at)과 마지막 사용 시각(used_at)을 집계 단위별로 센다. + * 사용 시각은 테이블마다 마지막 사용 한 번만 남으므로, 사용 횟수는 그 구간에 마지막으로 쓰인 테이블 수다. + */ +@Service +@RequiredArgsConstructor +public class TableUsageStatsService { + + private final CustomizeTableDomainRepository customizeTableDomainRepository; + + public TableUsageStatsResponse getStats(StatUnit unit, LocalDate from, LocalDate to) { + StatPeriod period = new StatPeriod(unit, from, to); + Map createdCounts = period.countByPeriod( + customizeTableDomainRepository.findCreatedAtBetween(period.getStartAt(), period.getEndAt())); + Map usedCounts = period.countByPeriod( + customizeTableDomainRepository.findUsedAtBetween(period.getStartAt(), period.getEndAt())); + + List periods = period.getPeriodStarts() + .stream() + .map(periodStart -> new TableUsageStatResponse( + periodStart, + createdCounts.getOrDefault(periodStart, 0L), + usedCounts.getOrDefault(periodStart, 0L) + )).toList(); + return new TableUsageStatsResponse(unit, period.getStart(), period.getEnd().minusDays(1), periods); + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java new file mode 100644 index 00000000..eae9dce7 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java @@ -0,0 +1,54 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.dto.admin.TableUsageStatsResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class AdminStatsControllerTest extends BaseControllerTest { + + private static final String PASSWORD = "test-admin-password"; + + @Nested + class GetTableUsageStats { + + @Test + void 어드민_비밀번호가_맞으면_단위별_테이블_통계를_조회한다() { + TableUsageStatsResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("unit", "WEEK") + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-14") + .when().get("/api/admin/tables/stats") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(TableUsageStatsResponse.class); + + assertThat(response.periods()).hasSize(3); + } + + @Test + void 어드민_비밀번호가_틀리면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, "wrong-password") + .queryParam("unit", "DAY") + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/tables/stats") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 집계_단위가_잘못되면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("unit", "YEAR") + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/tables/stats") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + } +} diff --git a/src/test/java/com/debatetimer/domain/stats/StatPeriodTest.java b/src/test/java/com/debatetimer/domain/stats/StatPeriodTest.java new file mode 100644 index 00000000..c22e4e12 --- /dev/null +++ b/src/test/java/com/debatetimer/domain/stats/StatPeriodTest.java @@ -0,0 +1,103 @@ +package com.debatetimer.domain.stats; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class StatPeriodTest { + + @Nested + class Validate { + + @Test + void 시작일이_종료일보다_늦으면_예외를_던진다() { + LocalDate from = LocalDate.of(2026, 10, 2); + LocalDate to = LocalDate.of(2026, 10, 1); + + assertThatThrownBy(() -> new StatPeriod(StatUnit.DAY, from, to)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_STATS_RANGE.getMessage()); + } + + @Test + void 구간이_최대_개수를_넘으면_예외를_던진다() { + LocalDate from = LocalDate.of(2026, 1, 1); + LocalDate to = from.plusDays(StatPeriod.MAX_PERIOD_COUNT); + + assertThatThrownBy(() -> new StatPeriod(StatUnit.DAY, from, to)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_STATS_RANGE.getMessage()); + } + + @Test + void 주간_단위는_일_단위보다_긴_기간을_조회할_수_있다() { + LocalDate from = LocalDate.of(2020, 1, 1); + LocalDate to = LocalDate.of(2026, 12, 31); + + StatPeriod period = new StatPeriod(StatUnit.WEEK, from, to); + + assertThat(period.getPeriodStarts()).hasSizeLessThanOrEqualTo(StatPeriod.MAX_PERIOD_COUNT); + } + } + + @Nested + class GetPeriodStarts { + + @Test + void 주간_단위는_월요일부터_시작하는_구간으로_나눈다() { + // 2026-10-01 목요일 ~ 2026-10-14 수요일 + StatPeriod period = new StatPeriod(StatUnit.WEEK, LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 14)); + + assertAll( + () -> assertThat(period.getPeriodStarts()).containsExactly( + LocalDate.of(2026, 9, 28), + LocalDate.of(2026, 10, 5), + LocalDate.of(2026, 10, 12) + ), + () -> assertThat(period.getEndAt()).isEqualTo(LocalDate.of(2026, 10, 19).atStartOfDay()) + ); + } + + @Test + void 월별_단위는_1일부터_시작하는_구간으로_나눈다() { + StatPeriod period = new StatPeriod(StatUnit.MONTH, LocalDate.of(2026, 1, 15), LocalDate.of(2026, 3, 2)); + + assertThat(period.getPeriodStarts()).containsExactly( + LocalDate.of(2026, 1, 1), + LocalDate.of(2026, 2, 1), + LocalDate.of(2026, 3, 1) + ); + } + } + + @Nested + class CountByPeriod { + + @Test + void 시각을_구간_시작일별로_센다() { + StatPeriod period = new StatPeriod(StatUnit.MONTH, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 2, 28)); + List times = List.of( + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2026, 1, 31, 23, 59), + LocalDateTime.of(2026, 2, 10, 12, 0), + LocalDateTime.of(2026, 3, 1, 0, 0) + ); + + Map counts = period.countByPeriod(times); + + assertThat(counts).containsExactlyInAnyOrderEntriesOf(Map.of( + LocalDate.of(2026, 1, 1), 2L, + LocalDate.of(2026, 2, 1), 1L + )); + } + } +} diff --git a/src/test/java/com/debatetimer/service/admin/TableUsageStatsServiceTest.java b/src/test/java/com/debatetimer/service/admin/TableUsageStatsServiceTest.java new file mode 100644 index 00000000..2c28840f --- /dev/null +++ b/src/test/java/com/debatetimer/service/admin/TableUsageStatsServiceTest.java @@ -0,0 +1,80 @@ +package com.debatetimer.service.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.dto.admin.TableUsageStatResponse; +import com.debatetimer.dto.admin.TableUsageStatsResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class TableUsageStatsServiceTest extends BaseServiceTest { + + private static final LocalDate DAY = LocalDate.of(2026, 10, 1); + + @Autowired + private TableUsageStatsService tableUsageStatsService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Member member; + + @BeforeEach + void setUp() { + member = memberGenerator.generate("member@email.com"); + } + + private void saveTable(LocalDateTime createdAt, LocalDateTime usedAt) { + CustomizeTableEntity table = customizeTableRepository.save(new CustomizeTableEntity( + new CustomizeTable(member, "테이블", "주제", "찬성", "반대", false, false, usedAt))); + jdbcTemplate.update("UPDATE customize_table SET created_at = ? WHERE id = ?", createdAt, table.getId()); + } + + @Nested + class GetStats { + + @Test + void 일별로_생성_수와_사용_수를_센다() { + saveTable(DAY.atTime(10, 0), DAY.plusDays(1).atTime(9, 0)); + saveTable(DAY.atTime(23, 59), DAY.atTime(23, 59)); + saveTable(DAY.minusDays(1).atTime(12, 0), DAY.plusDays(1).atTime(0, 0)); + + TableUsageStatsResponse response = tableUsageStatsService.getStats(StatUnit.DAY, DAY, DAY.plusDays(2)); + + assertThat(response.periods()).containsExactly( + new TableUsageStatResponse(DAY, 2, 1), + new TableUsageStatResponse(DAY.plusDays(1), 0, 2), + new TableUsageStatResponse(DAY.plusDays(2), 0, 0) + ); + } + + @Test + void 월별_단위는_조회_기간을_달_경계로_넓힌다() { + saveTable(LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 30, 23, 0)); + saveTable(LocalDateTime.of(2026, 10, 31, 23, 59), LocalDateTime.of(2026, 10, 31, 23, 59)); + + TableUsageStatsResponse response = tableUsageStatsService.getStats( + StatUnit.MONTH, LocalDate.of(2026, 9, 15), LocalDate.of(2026, 10, 15)); + + assertAll( + () -> assertThat(response.from()).isEqualTo(LocalDate.of(2026, 9, 1)), + () -> assertThat(response.to()).isEqualTo(LocalDate.of(2026, 10, 31)), + () -> assertThat(response.periods()).containsExactly( + new TableUsageStatResponse(LocalDate.of(2026, 9, 1), 1, 1), + new TableUsageStatResponse(LocalDate.of(2026, 10, 1), 1, 1) + ) + ); + } + } +} From 573fe1ddf64187bb681dcaf1df1d7fa0ef6e9b80 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 22 Oct 2026 01:19:05 +0900 Subject: [PATCH 02/11] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=EB=B3=84=20=ED=86=B5=EA=B3=84=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구간별 신규 회원 수, 누적 회원 수, 구간 시작 전 회원 대비 증가율을 제공한다. 회원 조회는 MemberDomainRepository를 통해서만 접근한다. Co-Authored-By: Claude Opus 5.5 --- .../admin/AdminStatsController.java | 15 +++++ .../member/MemberDomainRepository.java | 25 +++++++ .../dto/admin/MemberSignupStatResponse.java | 15 +++++ .../dto/admin/MemberSignupStatsResponse.java | 13 ++++ .../repository/member/MemberRepository.java | 8 +++ .../admin/MemberSignupStatsService.java | 48 ++++++++++++++ .../admin/AdminStatsControllerTest.java | 29 +++++++++ .../admin/MemberSignupStatsServiceTest.java | 65 +++++++++++++++++++ 8 files changed, 218 insertions(+) create mode 100644 src/main/java/com/debatetimer/domainrepository/member/MemberDomainRepository.java create mode 100644 src/main/java/com/debatetimer/dto/admin/MemberSignupStatResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/MemberSignupStatsResponse.java create mode 100644 src/main/java/com/debatetimer/service/admin/MemberSignupStatsService.java create mode 100644 src/test/java/com/debatetimer/service/admin/MemberSignupStatsServiceTest.java diff --git a/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java index 8008fb9a..56fd3761 100644 --- a/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java +++ b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java @@ -1,7 +1,9 @@ package com.debatetimer.controller.admin; import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.dto.admin.MemberSignupStatsResponse; import com.debatetimer.dto.admin.TableUsageStatsResponse; +import com.debatetimer.service.admin.MemberSignupStatsService; import com.debatetimer.service.admin.TableUsageStatsService; import java.time.LocalDate; import lombok.RequiredArgsConstructor; @@ -19,6 +21,7 @@ public class AdminStatsController { private final AdminAuthorizer adminAuthorizer; private final TableUsageStatsService tableUsageStatsService; + private final MemberSignupStatsService memberSignupStatsService; @GetMapping("/api/admin/tables/stats") @ResponseStatus(HttpStatus.OK) @@ -31,4 +34,16 @@ public TableUsageStatsResponse getTableUsageStats( adminAuthorizer.authorize(password); return tableUsageStatsService.getStats(unit, from, to); } + + @GetMapping("/api/admin/members/stats") + @ResponseStatus(HttpStatus.OK) + public MemberSignupStatsResponse getMemberSignupStats( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @RequestParam("unit") StatUnit unit, + @RequestParam("from") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam("to") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to + ) { + adminAuthorizer.authorize(password); + return memberSignupStatsService.getStats(unit, from, to); + } } diff --git a/src/main/java/com/debatetimer/domainrepository/member/MemberDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/member/MemberDomainRepository.java new file mode 100644 index 00000000..b0d0fbcc --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/member/MemberDomainRepository.java @@ -0,0 +1,25 @@ +package com.debatetimer.domainrepository.member; + +import com.debatetimer.repository.member.MemberRepository; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +@RequiredArgsConstructor +public class MemberDomainRepository { + + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public List findCreatedAtBetween(LocalDateTime from, LocalDateTime to) { + return memberRepository.findCreatedAtBetween(from, to); + } + + @Transactional(readOnly = true) + public long countCreatedBefore(LocalDateTime createdAt) { + return memberRepository.countByCreatedAtBefore(createdAt); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/MemberSignupStatResponse.java b/src/main/java/com/debatetimer/dto/admin/MemberSignupStatResponse.java new file mode 100644 index 00000000..46944716 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/MemberSignupStatResponse.java @@ -0,0 +1,15 @@ +package com.debatetimer.dto.admin; + +import jakarta.annotation.Nullable; +import java.time.LocalDate; + +/** + * @param growthRate 구간 시작 전 회원 수 대비 신규 회원 비율(%). 시작 전 회원이 없으면 비울 수 없어 null + */ +public record MemberSignupStatResponse( + LocalDate periodStart, + long newMemberCount, + long totalMemberCount, + @Nullable Double growthRate +) { +} diff --git a/src/main/java/com/debatetimer/dto/admin/MemberSignupStatsResponse.java b/src/main/java/com/debatetimer/dto/admin/MemberSignupStatsResponse.java new file mode 100644 index 00000000..7806cf4c --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/MemberSignupStatsResponse.java @@ -0,0 +1,13 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.stats.StatUnit; +import java.time.LocalDate; +import java.util.List; + +public record MemberSignupStatsResponse( + StatUnit unit, + LocalDate from, + LocalDate to, + List periods +) { +} diff --git a/src/main/java/com/debatetimer/repository/member/MemberRepository.java b/src/main/java/com/debatetimer/repository/member/MemberRepository.java index e09d370c..edfbee59 100644 --- a/src/main/java/com/debatetimer/repository/member/MemberRepository.java +++ b/src/main/java/com/debatetimer/repository/member/MemberRepository.java @@ -3,7 +3,10 @@ import com.debatetimer.domain.member.Member; import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; public interface MemberRepository extends Repository { @@ -23,4 +26,9 @@ default Member getByEmail(String email) { return findByEmail(email) .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.MEMBER_NOT_FOUND)); } + + @Query("SELECT m.createdAt FROM Member m WHERE m.createdAt >= :from AND m.createdAt < :to") + List findCreatedAtBetween(LocalDateTime from, LocalDateTime to); + + long countByCreatedAtBefore(LocalDateTime createdAt); } diff --git a/src/main/java/com/debatetimer/service/admin/MemberSignupStatsService.java b/src/main/java/com/debatetimer/service/admin/MemberSignupStatsService.java new file mode 100644 index 00000000..c03a2b56 --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/MemberSignupStatsService.java @@ -0,0 +1,48 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.stats.StatPeriod; +import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.domainrepository.member.MemberDomainRepository; +import com.debatetimer.dto.admin.MemberSignupStatResponse; +import com.debatetimer.dto.admin.MemberSignupStatsResponse; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 구간별 신규 가입 수와, 구간 시작 전 누적 회원 대비 증가 비율을 계산한다. + */ +@Service +@RequiredArgsConstructor +public class MemberSignupStatsService { + + private static final double PERCENT = 100.0; + + private final MemberDomainRepository memberDomainRepository; + + public MemberSignupStatsResponse getStats(StatUnit unit, LocalDate from, LocalDate to) { + StatPeriod period = new StatPeriod(unit, from, to); + Map newMemberCounts = period.countByPeriod( + memberDomainRepository.findCreatedAtBetween(period.getStartAt(), period.getEndAt())); + + long totalMemberCount = memberDomainRepository.countCreatedBefore(period.getStartAt()); + List periods = new ArrayList<>(); + for (LocalDate periodStart : period.getPeriodStarts()) { + long newMemberCount = newMemberCounts.getOrDefault(periodStart, 0L); + Double growthRate = calculateGrowthRate(newMemberCount, totalMemberCount); + totalMemberCount += newMemberCount; + periods.add(new MemberSignupStatResponse(periodStart, newMemberCount, totalMemberCount, growthRate)); + } + return new MemberSignupStatsResponse(unit, period.getStart(), period.getEnd().minusDays(1), periods); + } + + private Double calculateGrowthRate(long newMemberCount, long previousMemberCount) { + if (previousMemberCount == 0) { + return null; + } + return Math.round(newMemberCount * PERCENT / previousMemberCount * 10) / 10.0; + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java index eae9dce7..f51e5eb5 100644 --- a/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java +++ b/src/test/java/com/debatetimer/controller/admin/AdminStatsControllerTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.dto.admin.MemberSignupStatsResponse; import com.debatetimer.dto.admin.TableUsageStatsResponse; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -51,4 +52,32 @@ class GetTableUsageStats { .then().statusCode(HttpStatus.BAD_REQUEST.value()); } } + + @Nested + class GetMemberSignupStats { + + @Test + void 어드민_비밀번호가_맞으면_단위별_회원_통계를_조회한다() { + MemberSignupStatsResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("unit", "MONTH") + .queryParam("from", "2026-01-01") + .queryParam("to", "2026-12-31") + .when().get("/api/admin/members/stats") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(MemberSignupStatsResponse.class); + + assertThat(response.periods()).hasSize(12); + } + + @Test + void 어드민_비밀번호가_없으면_조회할_수_없다() { + given() + .queryParam("unit", "DAY") + .queryParam("from", "2026-10-01") + .queryParam("to", "2026-10-07") + .when().get("/api/admin/members/stats") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + } } diff --git a/src/test/java/com/debatetimer/service/admin/MemberSignupStatsServiceTest.java b/src/test/java/com/debatetimer/service/admin/MemberSignupStatsServiceTest.java new file mode 100644 index 00000000..50dea00c --- /dev/null +++ b/src/test/java/com/debatetimer/service/admin/MemberSignupStatsServiceTest.java @@ -0,0 +1,65 @@ +package com.debatetimer.service.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.stats.StatUnit; +import com.debatetimer.dto.admin.MemberSignupStatResponse; +import com.debatetimer.dto.admin.MemberSignupStatsResponse; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class MemberSignupStatsServiceTest extends BaseServiceTest { + + private static final LocalDate DAY = LocalDate.of(2026, 10, 1); + + @Autowired + private MemberSignupStatsService memberSignupStatsService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private int sequence = 0; + + private void saveMember(LocalDateTime createdAt) { + Member member = memberGenerator.generate("member%d@email.com".formatted(sequence++)); + jdbcTemplate.update("UPDATE member SET created_at = ? WHERE id = ?", createdAt, member.getId()); + } + + @Nested + class GetStats { + + @Test + void 구간별_신규_회원_수와_누적_회원_대비_증가율을_계산한다() { + saveMember(DAY.minusDays(1).atTime(10, 0)); + saveMember(DAY.minusDays(1).atTime(11, 0)); + saveMember(DAY.atTime(0, 0)); + saveMember(DAY.plusDays(1).atTime(12, 0)); + saveMember(DAY.plusDays(1).atTime(23, 59)); + + MemberSignupStatsResponse response = memberSignupStatsService.getStats(StatUnit.DAY, DAY, DAY.plusDays(2)); + + assertThat(response.periods()).containsExactly( + new MemberSignupStatResponse(DAY, 1, 3, 50.0), + new MemberSignupStatResponse(DAY.plusDays(1), 2, 5, 66.7), + new MemberSignupStatResponse(DAY.plusDays(2), 0, 5, 0.0) + ); + } + + @Test + void 구간_시작_전_회원이_없으면_증가율을_비운다() { + saveMember(DAY.atTime(9, 0)); + + MemberSignupStatsResponse response = memberSignupStatsService.getStats(StatUnit.MONTH, DAY, DAY); + + assertThat(response.periods()).containsExactly( + new MemberSignupStatResponse(DAY, 1, 1, null) + ); + } + } +} From 0d2c357c9ea7bce8e1c0d06dcc6efed945e7b4c7 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 22 Oct 2026 01:20:44 +0900 Subject: [PATCH 03/11] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 회원의 테이블을 created_at 내림차순으로 페이지 조회하고, 소유자 확인 없이 타임박스·종소리 설정까지 상세 조회한다. Co-Authored-By: Claude Opus 5.5 --- .../admin/AdminTableController.java | 42 +++++++ .../customize/CustomizeTableOverview.java | 16 +++ .../CustomizeTableDomainRepository.java | 18 +++ .../dto/admin/AdminTableDetailResponse.java | 36 ++++++ .../dto/admin/AdminTablePageResponse.java | 24 ++++ .../dto/admin/AdminTableResponse.java | 30 +++++ .../customize/CustomizeTableEntity.java | 5 + .../exception/errorcode/ClientErrorCode.java | 3 + .../customize/CustomizeTableRepository.java | 5 + .../service/admin/AdminTableService.java | 42 +++++++ .../admin/AdminTableControllerTest.java | 75 +++++++++++++ .../service/admin/AdminTableServiceTest.java | 104 ++++++++++++++++++ 12 files changed, 400 insertions(+) create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminTableController.java create mode 100644 src/main/java/com/debatetimer/domain/customize/CustomizeTableOverview.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminTableDetailResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminTablePageResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminTableResponse.java create mode 100644 src/main/java/com/debatetimer/service/admin/AdminTableService.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminTableControllerTest.java create mode 100644 src/test/java/com/debatetimer/service/admin/AdminTableServiceTest.java diff --git a/src/main/java/com/debatetimer/controller/admin/AdminTableController.java b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java new file mode 100644 index 00000000..10a59194 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java @@ -0,0 +1,42 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.dto.admin.AdminTableDetailResponse; +import com.debatetimer.dto.admin.AdminTablePageResponse; +import com.debatetimer.service.admin.AdminTableService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AdminTableController { + + private final AdminAuthorizer adminAuthorizer; + private final AdminTableService adminTableService; + + @GetMapping("/api/admin/tables") + @ResponseStatus(HttpStatus.OK) + public AdminTablePageResponse getTables( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "20") int size + ) { + adminAuthorizer.authorize(password); + return adminTableService.getTables(page, size); + } + + @GetMapping("/api/admin/tables/{tableId}") + @ResponseStatus(HttpStatus.OK) + public AdminTableDetailResponse getTable( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @PathVariable long tableId + ) { + adminAuthorizer.authorize(password); + return adminTableService.getTable(tableId); + } +} diff --git a/src/main/java/com/debatetimer/domain/customize/CustomizeTableOverview.java b/src/main/java/com/debatetimer/domain/customize/CustomizeTableOverview.java new file mode 100644 index 00000000..f498e154 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/customize/CustomizeTableOverview.java @@ -0,0 +1,16 @@ +package com.debatetimer.domain.customize; + +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 어드민에서 테이블을 생성 순으로 살펴보기 위해 테이블과 생성 시각을 함께 담는다. + */ +@Getter +@RequiredArgsConstructor +public class CustomizeTableOverview { + + private final CustomizeTable table; + private final LocalDateTime createdAt; +} diff --git a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java index ab1cbe04..540b27d2 100644 --- a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java @@ -1,6 +1,7 @@ package com.debatetimer.domainrepository.customize; import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTableOverview; import com.debatetimer.domain.customize.CustomizeTimeBox; import com.debatetimer.domain.member.Member; import com.debatetimer.entity.customize.BellEntity; @@ -17,6 +18,10 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -66,6 +71,19 @@ public CustomizeTable getWithMemberById(long tableId) { .toDomain(); } + @Transactional(readOnly = true) + public CustomizeTableOverview getOverviewById(long tableId) { + return tableRepository.getWithMemberById(tableId) + .toOverview(); + } + + @Transactional(readOnly = true) + public Page findOverviewsOrderByCreatedAtDesc(int page, int size) { + Sort latestFirst = Sort.by(Direction.DESC, "createdAt", "id"); + return tableRepository.findAllBy(PageRequest.of(page, size, latestFirst)) + .map(CustomizeTableEntity::toOverview); + } + @Transactional(readOnly = true) public List getMemberCustomizeTimeBoxes(long tableId, Member member) { CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member); diff --git a/src/main/java/com/debatetimer/dto/admin/AdminTableDetailResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminTableDetailResponse.java new file mode 100644 index 00000000..85377045 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminTableDetailResponse.java @@ -0,0 +1,36 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTableOverview; +import com.debatetimer.domain.customize.CustomizeTimeBox; +import com.debatetimer.dto.customize.response.CustomizeTableInfoResponse; +import com.debatetimer.dto.customize.response.CustomizeTimeBoxResponse; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminTableDetailResponse( + long id, + String memberEmail, + LocalDateTime createdAt, + LocalDateTime usedAt, + CustomizeTableInfoResponse info, + List table +) { + + public AdminTableDetailResponse(CustomizeTableOverview overview, List timeBoxes) { + this(overview.getTable(), overview.getCreatedAt(), timeBoxes); + } + + private AdminTableDetailResponse(CustomizeTable table, LocalDateTime createdAt, List timeBoxes) { + this( + table.getId(), + table.getMember().getEmail(), + createdAt, + table.getUsedAt(), + new CustomizeTableInfoResponse(table), + timeBoxes.stream() + .map(CustomizeTimeBoxResponse::new) + .toList() + ); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/AdminTablePageResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminTablePageResponse.java new file mode 100644 index 00000000..9b927ec2 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminTablePageResponse.java @@ -0,0 +1,24 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.customize.CustomizeTableOverview; +import java.util.List; +import org.springframework.data.domain.Page; + +public record AdminTablePageResponse( + List tables, + int page, + int size, + long totalElements, + int totalPages +) { + + public static AdminTablePageResponse from(Page overviews) { + return new AdminTablePageResponse( + overviews.map(AdminTableResponse::new).getContent(), + overviews.getNumber(), + overviews.getSize(), + overviews.getTotalElements(), + overviews.getTotalPages() + ); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/AdminTableResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminTableResponse.java new file mode 100644 index 00000000..e524df48 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminTableResponse.java @@ -0,0 +1,30 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTableOverview; +import java.time.LocalDateTime; + +public record AdminTableResponse( + long id, + String name, + String agenda, + String memberEmail, + LocalDateTime createdAt, + LocalDateTime usedAt +) { + + public AdminTableResponse(CustomizeTableOverview overview) { + this(overview.getTable(), overview.getCreatedAt()); + } + + private AdminTableResponse(CustomizeTable table, LocalDateTime createdAt) { + this( + table.getId(), + table.getName(), + table.getAgenda(), + table.getMember().getEmail(), + createdAt, + table.getUsedAt() + ); + } +} diff --git a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java index e10a9307..4132d05b 100644 --- a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java +++ b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java @@ -1,6 +1,7 @@ package com.debatetimer.entity.customize; import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTableOverview; import com.debatetimer.domain.member.Member; import com.debatetimer.dto.member.TableType; import com.debatetimer.entity.BaseTimeEntity; @@ -77,6 +78,10 @@ public CustomizeTable toDomain() { ); } + public CustomizeTableOverview toOverview() { + return new CustomizeTableOverview(toDomain(), getCreatedAt()); + } + public void updateTable(CustomizeTable renewTable) { this.name = renewTable.getName(); this.agenda = renewTable.getAgenda(); diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index edb125d8..c6bc7609 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -6,6 +6,7 @@ import com.debatetimer.domain.customize.TeamName; import com.debatetimer.domain.stats.StatPeriod; import com.debatetimer.entity.customize.CustomizeTimeBoxEntity; +import com.debatetimer.service.admin.AdminTableService; import com.debatetimer.service.admin.SharingStatsService; import lombok.Getter; import org.springframework.http.HttpStatus; @@ -92,6 +93,8 @@ public enum ClientErrorCode implements ResponseErrorCode { .formatted(SharingStatsService.MAX_PERIOD_DAYS)), INVALID_STATS_RANGE(HttpStatus.BAD_REQUEST, "조회 기간은 시작일이 종료일보다 늦을 수 없고, 최대 %d개 구간까지 조회할 수 있습니다" .formatted(StatPeriod.MAX_PERIOD_COUNT)), + INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "페이지는 0 이상, 페이지 크기는 1 이상 %d 이하여야 합니다" + .formatted(AdminTableService.MAX_PAGE_SIZE)), ; private final HttpStatus status; diff --git a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java index bc77b1f8..409accdf 100644 --- a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java +++ b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java @@ -7,6 +7,8 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; @@ -40,6 +42,9 @@ default CustomizeTableEntity getWithMemberById(long tableId) { .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.TABLE_NOT_FOUND)); } + @EntityGraph(attributePaths = "member") + Page findAllBy(Pageable pageable); + @Query("SELECT t.createdAt FROM CustomizeTableEntity t WHERE t.createdAt >= :from AND t.createdAt < :to") List findCreatedAtBetween(LocalDateTime from, LocalDateTime to); diff --git a/src/main/java/com/debatetimer/service/admin/AdminTableService.java b/src/main/java/com/debatetimer/service/admin/AdminTableService.java new file mode 100644 index 00000000..480127a6 --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/AdminTableService.java @@ -0,0 +1,42 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.customize.CustomizeTableOverview; +import com.debatetimer.domain.customize.CustomizeTimeBox; +import com.debatetimer.domainrepository.customize.CustomizeTableDomainRepository; +import com.debatetimer.dto.admin.AdminTableDetailResponse; +import com.debatetimer.dto.admin.AdminTablePageResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * 어드민이 모든 회원의 테이블을 소유자 확인 없이 읽기 전용으로 조회한다. + */ +@Service +@RequiredArgsConstructor +public class AdminTableService { + + public static final int MAX_PAGE_SIZE = 100; + + private final CustomizeTableDomainRepository customizeTableDomainRepository; + + public AdminTablePageResponse getTables(int page, int size) { + validatePage(page, size); + return AdminTablePageResponse.from( + customizeTableDomainRepository.findOverviewsOrderByCreatedAtDesc(page, size)); + } + + private void validatePage(int page, int size) { + if (page < 0 || size <= 0 || size > MAX_PAGE_SIZE) { + throw new DTClientErrorException(ClientErrorCode.INVALID_PAGE_REQUEST); + } + } + + public AdminTableDetailResponse getTable(long tableId) { + CustomizeTableOverview overview = customizeTableDomainRepository.getOverviewById(tableId); + List timeBoxes = customizeTableDomainRepository.getCustomizeTimeBoxes(tableId); + return new AdminTableDetailResponse(overview, timeBoxes); + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminTableControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminTableControllerTest.java new file mode 100644 index 00000000..a8552175 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminTableControllerTest.java @@ -0,0 +1,75 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.admin.AdminTableDetailResponse; +import com.debatetimer.dto.admin.AdminTablePageResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class AdminTableControllerTest extends BaseControllerTest { + + private static final String PASSWORD = "test-admin-password"; + + @Nested + class GetTables { + + @Test + void 어드민_비밀번호가_맞으면_테이블_목록을_조회한다() { + Member member = memberGenerator.generate("member@email.com"); + customizeTableEntityGenerator.generate(member); + customizeTableEntityGenerator.generate(member); + + AdminTablePageResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .queryParam("page", 0) + .queryParam("size", 1) + .when().get("/api/admin/tables") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(AdminTablePageResponse.class); + + assertThat(response.totalElements()).isEqualTo(2); + } + + @Test + void 어드민_비밀번호가_틀리면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, "wrong-password") + .when().get("/api/admin/tables") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + } + + @Nested + class GetTable { + + @Test + void 어드민_비밀번호가_맞으면_테이블_상세를_조회한다() { + Member member = memberGenerator.generate("member@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + + AdminTableDetailResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .pathParam("tableId", table.getId()) + .when().get("/api/admin/tables/{tableId}") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(AdminTableDetailResponse.class); + + assertThat(response.table()).hasSize(1); + } + + @Test + void 어드민_비밀번호가_없으면_조회할_수_없다() { + given() + .pathParam("tableId", 1) + .when().get("/api/admin/tables/{tableId}") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + } +} diff --git a/src/test/java/com/debatetimer/service/admin/AdminTableServiceTest.java b/src/test/java/com/debatetimer/service/admin/AdminTableServiceTest.java new file mode 100644 index 00000000..58e584a5 --- /dev/null +++ b/src/test/java/com/debatetimer/service/admin/AdminTableServiceTest.java @@ -0,0 +1,104 @@ +package com.debatetimer.service.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.customize.BellType; +import com.debatetimer.domain.customize.CustomizeBoxType; +import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.member.Member; +import com.debatetimer.dto.admin.AdminTableDetailResponse; +import com.debatetimer.dto.admin.AdminTablePageResponse; +import com.debatetimer.dto.admin.AdminTableResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.customize.CustomizeTimeBoxEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.service.BaseServiceTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +class AdminTableServiceTest extends BaseServiceTest { + + @Autowired + private AdminTableService adminTableService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private CustomizeTableEntity saveTable(Member member, LocalDateTime createdAt) { + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + jdbcTemplate.update("UPDATE customize_table SET created_at = ? WHERE id = ?", createdAt, table.getId()); + return table; + } + + @Nested + class GetTables { + + @Test + void 모든_회원의_테이블을_최신_생성순으로_페이지로_조회한다() { + Member member1 = memberGenerator.generate("member1@email.com"); + Member member2 = memberGenerator.generate("member2@email.com"); + CustomizeTableEntity oldest = saveTable(member1, LocalDateTime.of(2026, 10, 1, 0, 0)); + CustomizeTableEntity latest = saveTable(member2, LocalDateTime.of(2026, 10, 3, 0, 0)); + CustomizeTableEntity middle = saveTable(member1, LocalDateTime.of(2026, 10, 2, 0, 0)); + + AdminTablePageResponse firstPage = adminTableService.getTables(0, 2); + AdminTablePageResponse secondPage = adminTableService.getTables(1, 2); + + assertAll( + () -> assertThat(firstPage.tables()).extracting(AdminTableResponse::id) + .containsExactly(latest.getId(), middle.getId()), + () -> assertThat(firstPage.tables().get(0).memberEmail()).isEqualTo("member2@email.com"), + () -> assertThat(firstPage.totalElements()).isEqualTo(3), + () -> assertThat(firstPage.totalPages()).isEqualTo(2), + () -> assertThat(secondPage.tables()).extracting(AdminTableResponse::id) + .containsExactly(oldest.getId()) + ); + } + + @ParameterizedTest + @CsvSource({"-1, 20", "0, 0", "0, 101"}) + void 페이지_요청이_잘못되면_예외를_던진다(int page, int size) { + assertThatThrownBy(() -> adminTableService.getTables(page, size)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.INVALID_PAGE_REQUEST.getMessage()); + } + } + + @Nested + class GetTable { + + @Test + void 소유자와_관계없이_테이블과_타임박스_종소리를_조회한다() { + Member member = memberGenerator.generate("member@email.com"); + CustomizeTableEntity table = customizeTableEntityGenerator.generate(member); + CustomizeTimeBoxEntity normal = customizeTimeBoxEntityGenerator.generate(table, CustomizeBoxType.NORMAL, 1); + customizeTimeBoxRepository.save(new CustomizeTimeBoxEntity( + table, 2, Stance.NEUTRAL, "자유토론", CustomizeBoxType.TIME_BASED, 120, 60, null)); + bellEntityGenerator.generate(normal, BellType.BEFORE_END, 30, 1); + + AdminTableDetailResponse response = adminTableService.getTable(table.getId()); + + assertAll( + () -> assertThat(response.memberEmail()).isEqualTo("member@email.com"), + () -> assertThat(response.table()).hasSize(2), + () -> assertThat(response.table().get(0).bell()).hasSize(1), + () -> assertThat(response.table().get(1).boxType()).isEqualTo(CustomizeBoxType.TIME_BASED) + ); + } + + @Test + void 테이블이_없으면_예외를_던진다() { + assertThatThrownBy(() -> adminTableService.getTable(999L)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.TABLE_NOT_FOUND.getMessage()); + } + } +} From 5d43b6daa4a774915eae8c062911ed1a89fd0505 Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 22 Oct 2026 01:22:18 +0900 Subject: [PATCH 04/11] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=B2=B4=C2=B7=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 언어와 관계없이 모든 단체와 템플릿을 조회하고, 단체와 단체별 템플릿을 추가한다. 템플릿 언어는 소속 단체의 언어를 따른다. Co-Authored-By: Claude Opus 5.5 --- .../admin/AdminOrganizationController.java | 56 ++++++++ .../domain/organization/Organization.java | 7 + .../organization/OrganizationTemplate.java | 4 + .../OrganizationDomainRepository.java | 30 ++++ .../dto/admin/AdminOrganizationResponse.java | 29 ++++ .../AdminOrganizationTemplateResponse.java | 10 ++ .../dto/admin/AdminOrganizationsResponse.java | 13 ++ .../request/OrganizationCreateRequest.java | 26 ++++ .../OrganizationTemplateCreateRequest.java | 18 +++ .../organization/OrganizationEntity.java | 7 +- .../OrganizationTemplateEntity.java | 5 + .../exception/errorcode/ClientErrorCode.java | 1 + .../organization/OrganizationRepository.java | 12 ++ .../OrganizationTemplateRepository.java | 2 + .../admin/AdminOrganizationService.java | 31 ++++ .../AdminOrganizationControllerTest.java | 134 ++++++++++++++++++ .../OrganizationDomainRepositoryTest.java | 64 +++++++++ 17 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminOrganizationResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminOrganizationTemplateResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/AdminOrganizationsResponse.java create mode 100644 src/main/java/com/debatetimer/dto/admin/request/OrganizationCreateRequest.java create mode 100644 src/main/java/com/debatetimer/dto/admin/request/OrganizationTemplateCreateRequest.java create mode 100644 src/main/java/com/debatetimer/service/admin/AdminOrganizationService.java create mode 100644 src/test/java/com/debatetimer/controller/admin/AdminOrganizationControllerTest.java diff --git a/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java b/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java new file mode 100644 index 00000000..74024ac5 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminOrganizationController.java @@ -0,0 +1,56 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.dto.admin.AdminOrganizationResponse; +import com.debatetimer.dto.admin.AdminOrganizationTemplateResponse; +import com.debatetimer.dto.admin.AdminOrganizationsResponse; +import com.debatetimer.dto.admin.request.OrganizationCreateRequest; +import com.debatetimer.dto.admin.request.OrganizationTemplateCreateRequest; +import com.debatetimer.service.admin.AdminOrganizationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class AdminOrganizationController { + + private final AdminAuthorizer adminAuthorizer; + private final AdminOrganizationService adminOrganizationService; + + @GetMapping("/api/admin/organizations") + @ResponseStatus(HttpStatus.OK) + public AdminOrganizationsResponse getOrganizations( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password + ) { + adminAuthorizer.authorize(password); + return adminOrganizationService.findAll(); + } + + @PostMapping("/api/admin/organizations") + @ResponseStatus(HttpStatus.CREATED) + public AdminOrganizationResponse createOrganization( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @Valid @RequestBody OrganizationCreateRequest request + ) { + adminAuthorizer.authorize(password); + return adminOrganizationService.create(request); + } + + @PostMapping("/api/admin/organizations/{organizationId}/templates") + @ResponseStatus(HttpStatus.CREATED) + public AdminOrganizationTemplateResponse createTemplate( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @PathVariable long organizationId, + @Valid @RequestBody OrganizationTemplateCreateRequest request + ) { + adminAuthorizer.authorize(password); + return adminOrganizationService.createTemplate(organizationId, request); + } +} diff --git a/src/main/java/com/debatetimer/domain/organization/Organization.java b/src/main/java/com/debatetimer/domain/organization/Organization.java index 23b0b219..8aab069a 100644 --- a/src/main/java/com/debatetimer/domain/organization/Organization.java +++ b/src/main/java/com/debatetimer/domain/organization/Organization.java @@ -10,17 +10,24 @@ public class Organization { private final String name; private final String affiliation; private final String iconPath; + private final Language language; private final List templates; public Organization(Long id, String name, String affiliation, String iconPath, + Language language, List templates) { this.id = id; this.name = name; this.affiliation = affiliation; this.iconPath = iconPath; + this.language = language; this.templates = templates; } + + public Organization(String name, String affiliation, String iconPath, Language language) { + this(null, name, affiliation, iconPath, language, List.of()); + } } diff --git a/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java index 6c920f38..5f8a2c2c 100644 --- a/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java +++ b/src/main/java/com/debatetimer/domain/organization/OrganizationTemplate.java @@ -14,4 +14,8 @@ public OrganizationTemplate(Long id, String name, String data) { this.name = name; this.data = data; } + + public OrganizationTemplate(String name, String data) { + this(null, name, data); + } } diff --git a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java index 46080303..bd60121e 100644 --- a/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepository.java @@ -3,6 +3,7 @@ import com.debatetimer.domain.organization.Language; import com.debatetimer.domain.organization.Organization; import com.debatetimer.domain.organization.OrganizationTemplate; +import com.debatetimer.entity.organization.OrganizationEntity; import com.debatetimer.entity.organization.OrganizationTemplateEntity; import com.debatetimer.repository.organization.OrganizationRepository; import com.debatetimer.repository.organization.OrganizationTemplateRepository; @@ -12,6 +13,7 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository @RequiredArgsConstructor @@ -34,4 +36,32 @@ public List findAllByLanguage(Language language) { idToTemplatesEntity.getOrDefault(entity.getId(), Collections.emptyList()) )).toList(); } + + @Transactional(readOnly = true) + public List findAll() { + Map> idToTemplates = organizationTemplateRepository.findAllByOrderByIdAsc() + .stream() + .collect(Collectors.groupingBy( + OrganizationTemplateEntity::getOrganizationId, + Collectors.mapping(OrganizationTemplateEntity::toDomain, Collectors.toList()) + )); + + return organizationRepository.findAllByOrderByIdAsc() + .stream() + .map(entity -> entity.toDomain(idToTemplates.getOrDefault(entity.getId(), Collections.emptyList()))) + .toList(); + } + + @Transactional + public Organization create(Organization organization) { + return organizationRepository.save(new OrganizationEntity(organization)) + .toDomain(Collections.emptyList()); + } + + @Transactional + public OrganizationTemplate createTemplate(long organizationId, OrganizationTemplate template) { + OrganizationEntity organization = organizationRepository.getById(organizationId); + return organizationTemplateRepository.save(new OrganizationTemplateEntity(organization, template)) + .toDomain(); + } } diff --git a/src/main/java/com/debatetimer/dto/admin/AdminOrganizationResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationResponse.java new file mode 100644 index 00000000..dc3f922a --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationResponse.java @@ -0,0 +1,29 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.organization.Language; +import com.debatetimer.domain.organization.Organization; +import java.util.List; + +public record AdminOrganizationResponse( + long id, + String name, + String affiliation, + String iconPath, + Language language, + List templates +) { + + public AdminOrganizationResponse(Organization organization) { + this( + organization.getId(), + organization.getName(), + organization.getAffiliation(), + organization.getIconPath(), + organization.getLanguage(), + organization.getTemplates() + .stream() + .map(AdminOrganizationTemplateResponse::new) + .toList() + ); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/AdminOrganizationTemplateResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationTemplateResponse.java new file mode 100644 index 00000000..817b0f71 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationTemplateResponse.java @@ -0,0 +1,10 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.organization.OrganizationTemplate; + +public record AdminOrganizationTemplateResponse(long id, String name, String data) { + + public AdminOrganizationTemplateResponse(OrganizationTemplate template) { + this(template.getId(), template.getName(), template.getData()); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/AdminOrganizationsResponse.java b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationsResponse.java new file mode 100644 index 00000000..750f0aea --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/AdminOrganizationsResponse.java @@ -0,0 +1,13 @@ +package com.debatetimer.dto.admin; + +import com.debatetimer.domain.organization.Organization; +import java.util.List; + +public record AdminOrganizationsResponse(List organizations) { + + public static AdminOrganizationsResponse from(List organizations) { + return new AdminOrganizationsResponse(organizations.stream() + .map(AdminOrganizationResponse::new) + .toList()); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/request/OrganizationCreateRequest.java b/src/main/java/com/debatetimer/dto/admin/request/OrganizationCreateRequest.java new file mode 100644 index 00000000..24b4fae0 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/request/OrganizationCreateRequest.java @@ -0,0 +1,26 @@ +package com.debatetimer.dto.admin.request; + +import com.debatetimer.domain.organization.Language; +import com.debatetimer.domain.organization.Organization; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record OrganizationCreateRequest( + @NotBlank @Size(max = 255) + String name, + + @NotNull @Size(max = 255) + String affiliation, + + @NotBlank @Size(max = 255) + String iconPath, + + @NotNull + Language language +) { + + public Organization toOrganization() { + return new Organization(name, affiliation, iconPath, language); + } +} diff --git a/src/main/java/com/debatetimer/dto/admin/request/OrganizationTemplateCreateRequest.java b/src/main/java/com/debatetimer/dto/admin/request/OrganizationTemplateCreateRequest.java new file mode 100644 index 00000000..efd3f08f --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/request/OrganizationTemplateCreateRequest.java @@ -0,0 +1,18 @@ +package com.debatetimer.dto.admin.request; + +import com.debatetimer.domain.organization.OrganizationTemplate; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record OrganizationTemplateCreateRequest( + @NotBlank @Size(max = 255) + String name, + + @NotBlank @Size(max = 8191) + String data +) { + + public OrganizationTemplate toTemplate() { + return new OrganizationTemplate(name, data); + } +} diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java index 6d5d891b..fc3aaec7 100644 --- a/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationEntity.java @@ -48,7 +48,12 @@ public OrganizationEntity(String name, String affiliation, String iconPath, Lang this.language = language; } + public OrganizationEntity(Organization organization) { + this(organization.getName(), organization.getAffiliation(), organization.getIconPath(), + organization.getLanguage()); + } + public Organization toDomain(List templates) { - return new Organization(this.id, this.name, this.affiliation, this.iconPath, templates); + return new Organization(this.id, this.name, this.affiliation, this.iconPath, this.language, templates); } } diff --git a/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java index 26b7c686..8ce7f732 100644 --- a/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java +++ b/src/main/java/com/debatetimer/entity/organization/OrganizationTemplateEntity.java @@ -54,6 +54,11 @@ public OrganizationTemplateEntity(OrganizationEntity organization, String name, this.language = language; } + // 템플릿은 단체와 같은 언어로 조회되므로 단체의 언어를 그대로 따른다 + public OrganizationTemplateEntity(OrganizationEntity organization, OrganizationTemplate template) { + this(organization, template.getName(), template.getData(), organization.getLanguage()); + } + public OrganizationTemplate toDomain() { return new OrganizationTemplate(this.id, this.name, this.data); } diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index c6bc7609..62fc2362 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -67,6 +67,7 @@ public enum ClientErrorCode implements ResponseErrorCode { EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰 기한이 만료되었습니다"), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원이 존재하지 않습니다"), + ORGANIZATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 단체가 존재하지 않습니다"), INVALID_OAUTH_REQUEST(HttpStatus.BAD_REQUEST, "구글 OAuth 요청 파라미터 값이 잘못되었습니다."), diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java index 5b70760c..051f7e4a 100644 --- a/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationRepository.java @@ -2,12 +2,24 @@ import com.debatetimer.domain.organization.Language; import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; import java.util.List; +import java.util.Optional; import org.springframework.data.repository.Repository; public interface OrganizationRepository extends Repository { List findAllByLanguage(Language language); + List findAllByOrderByIdAsc(); + + Optional findById(long id); + + default OrganizationEntity getById(long id) { + return findById(id) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.ORGANIZATION_NOT_FOUND)); + } + OrganizationEntity save(OrganizationEntity organizationEntity); } diff --git a/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java index 0a11cc06..b1756249 100644 --- a/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java +++ b/src/main/java/com/debatetimer/repository/organization/OrganizationTemplateRepository.java @@ -9,5 +9,7 @@ public interface OrganizationTemplateRepository extends Repository findAllByLanguage(Language language); + List findAllByOrderByIdAsc(); + OrganizationTemplateEntity save(OrganizationTemplateEntity entity); } diff --git a/src/main/java/com/debatetimer/service/admin/AdminOrganizationService.java b/src/main/java/com/debatetimer/service/admin/AdminOrganizationService.java new file mode 100644 index 00000000..760693bf --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/AdminOrganizationService.java @@ -0,0 +1,31 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domainrepository.organization.OrganizationDomainRepository; +import com.debatetimer.dto.admin.AdminOrganizationResponse; +import com.debatetimer.dto.admin.AdminOrganizationTemplateResponse; +import com.debatetimer.dto.admin.AdminOrganizationsResponse; +import com.debatetimer.dto.admin.request.OrganizationCreateRequest; +import com.debatetimer.dto.admin.request.OrganizationTemplateCreateRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminOrganizationService { + + private final OrganizationDomainRepository organizationDomainRepository; + + public AdminOrganizationsResponse findAll() { + return AdminOrganizationsResponse.from(organizationDomainRepository.findAll()); + } + + public AdminOrganizationResponse create(OrganizationCreateRequest request) { + return new AdminOrganizationResponse(organizationDomainRepository.create(request.toOrganization())); + } + + public AdminOrganizationTemplateResponse createTemplate(long organizationId, + OrganizationTemplateCreateRequest request) { + return new AdminOrganizationTemplateResponse( + organizationDomainRepository.createTemplate(organizationId, request.toTemplate())); + } +} diff --git a/src/test/java/com/debatetimer/controller/admin/AdminOrganizationControllerTest.java b/src/test/java/com/debatetimer/controller/admin/AdminOrganizationControllerTest.java new file mode 100644 index 00000000..14d83cde --- /dev/null +++ b/src/test/java/com/debatetimer/controller/admin/AdminOrganizationControllerTest.java @@ -0,0 +1,134 @@ +package com.debatetimer.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.organization.Language; +import com.debatetimer.dto.admin.AdminOrganizationResponse; +import com.debatetimer.dto.admin.AdminOrganizationTemplateResponse; +import com.debatetimer.dto.admin.AdminOrganizationsResponse; +import com.debatetimer.dto.admin.request.OrganizationCreateRequest; +import com.debatetimer.dto.admin.request.OrganizationTemplateCreateRequest; +import com.debatetimer.entity.organization.OrganizationEntity; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +class AdminOrganizationControllerTest extends BaseControllerTest { + + private static final String PASSWORD = "test-admin-password"; + + @Nested + class GetOrganizations { + + @Test + void 어드민_비밀번호가_맞으면_모든_단체와_템플릿을_조회한다() { + OrganizationEntity korean = organizationEntityGenerator.generate("한앎", "한양대", Language.KO_KR); + organizationEntityGenerator.generate("english", "hanyang", Language.US_EN); + organizationTemplateEntityGenerator.generate(korean, "템플릿"); + + AdminOrganizationsResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .when().get("/api/admin/organizations") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(AdminOrganizationsResponse.class); + + assertAll( + () -> assertThat(response.organizations()).hasSize(2), + () -> assertThat(response.organizations().get(0).templates()).hasSize(1) + ); + } + + @Test + void 어드민_비밀번호가_틀리면_조회할_수_없다() { + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, "wrong-password") + .when().get("/api/admin/organizations") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + } + + @Nested + class CreateOrganization { + + @Test + void 단체를_추가한다() { + OrganizationCreateRequest request = new OrganizationCreateRequest( + "한앎", "한양대", "/icon.png", Language.KO_KR); + + AdminOrganizationResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/admin/organizations") + .then().statusCode(HttpStatus.CREATED.value()) + .extract().as(AdminOrganizationResponse.class); + + assertAll( + () -> assertThat(response.name()).isEqualTo("한앎"), + () -> assertThat(response.templates()).isEmpty() + ); + } + + @Test + void 단체_이름이_비어_있으면_추가할_수_없다() { + OrganizationCreateRequest request = new OrganizationCreateRequest( + " ", "한양대", "/icon.png", Language.KO_KR); + + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/admin/organizations") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + void 어드민_비밀번호가_없으면_추가할_수_없다() { + OrganizationCreateRequest request = new OrganizationCreateRequest( + "한앎", "한양대", "/icon.png", Language.KO_KR); + + given() + .contentType(ContentType.JSON) + .body(request) + .when().post("/api/admin/organizations") + .then().statusCode(HttpStatus.UNAUTHORIZED.value()); + } + } + + @Nested + class CreateTemplate { + + @Test + void 단체에_템플릿을_추가한다() { + OrganizationEntity organization = organizationEntityGenerator.generate("한앎", "한양대"); + OrganizationTemplateCreateRequest request = new OrganizationTemplateCreateRequest("템플릿", "data"); + + AdminOrganizationTemplateResponse response = given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .contentType(ContentType.JSON) + .pathParam("organizationId", organization.getId()) + .body(request) + .when().post("/api/admin/organizations/{organizationId}/templates") + .then().statusCode(HttpStatus.CREATED.value()) + .extract().as(AdminOrganizationTemplateResponse.class); + + assertThat(response.name()).isEqualTo("템플릿"); + } + + @Test + void 단체가_없으면_추가할_수_없다() { + OrganizationTemplateCreateRequest request = new OrganizationTemplateCreateRequest("템플릿", "data"); + + given() + .header(AdminSharingController.ADMIN_PASSWORD_HEADER, PASSWORD) + .contentType(ContentType.JSON) + .pathParam("organizationId", 999) + .body(request) + .when().post("/api/admin/organizations/{organizationId}/templates") + .then().statusCode(HttpStatus.NOT_FOUND.value()); + } + } +} diff --git a/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java index 821f7626..2bb3ebd5 100644 --- a/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/organization/OrganizationDomainRepositoryTest.java @@ -1,12 +1,16 @@ package com.debatetimer.domainrepository.organization; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.debatetimer.domain.organization.Language; import com.debatetimer.domain.organization.Organization; +import com.debatetimer.domain.organization.OrganizationTemplate; import com.debatetimer.domainrepository.BaseDomainRepositoryTest; import com.debatetimer.entity.organization.OrganizationEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; import java.util.List; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -54,4 +58,64 @@ class FindAllByLanguage { ); } } + + @Nested + class FindAll { + + @Test + void 언어와_관계없이_모든_단체와_템플릿을_가져온다() { + OrganizationEntity korean = organizationEntityGenerator.generate("한앎", "한양대", Language.KO_KR); + OrganizationEntity english = organizationEntityGenerator.generate("english", "hanyang", Language.US_EN); + organizationTemplateEntityGenerator.generate(korean, "템플릿1", Language.KO_KR); + organizationTemplateEntityGenerator.generate(english, "template1", Language.US_EN); + + List organizations = organizationDomainRepository.findAll(); + + assertAll( + () -> assertThat(organizations).extracting(Organization::getName) + .containsExactly("한앎", "english"), + () -> assertThat(organizations.get(1).getLanguage()).isEqualTo(Language.US_EN), + () -> assertThat(organizations.get(1).getTemplates()).hasSize(1) + ); + } + } + + @Nested + class Create { + + @Test + void 단체를_저장한다() { + Organization saved = organizationDomainRepository.create( + new Organization("한앎", "한양대", "/icon.png", Language.KO_KR)); + + assertAll( + () -> assertThat(saved.getId()).isNotNull(), + () -> assertThat(organizationDomainRepository.findAllByLanguage(Language.KO_KR)).hasSize(1) + ); + } + } + + @Nested + class CreateTemplate { + + @Test + void 템플릿을_단체와_같은_언어로_저장한다() { + OrganizationEntity english = organizationEntityGenerator.generate("english", "hanyang", Language.US_EN); + + organizationDomainRepository.createTemplate(english.getId(), new OrganizationTemplate("template", "data")); + + List organizations = organizationDomainRepository.findAllByLanguage(Language.US_EN); + assertThat(organizations.get(0).getTemplates()).extracting(OrganizationTemplate::getName) + .containsExactly("template"); + } + + @Test + void 단체가_없으면_예외를_던진다() { + OrganizationTemplate template = new OrganizationTemplate("template", "data"); + + assertThatThrownBy(() -> organizationDomainRepository.createTemplate(999L, template)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.ORGANIZATION_NOT_FOUND.getMessage()); + } + } } From a8e4cbdc83b268442e8731349d2e8a09538a4bbd Mon Sep 17 00:00:00 2001 From: coli Date: Thu, 22 Oct 2026 01:28:14 +0900 Subject: [PATCH 05/11] =?UTF-8?q?[FEAT]=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8=C2=B7=ED=9A=8C=EC=9B=90=20=ED=86=B5=EA=B3=84,=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=AA=A9=EB=A1=9D,=20=EB=8B=A8?= =?UTF-8?q?=EC=B2=B4=C2=B7=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일/주/월 단위 타이머 생성·사용 막대 그래프와 회원 증가율 그래프 - 최신 생성순 테이블 페이지 목록과 읽기 전용 타임박스 모달(종소리 등 세부 설정) - 단체·템플릿 목록, 단체 추가, 테이블 ID로 템플릿 데이터를 만들어 추가하는 폼 Co-Authored-By: Claude Opus 5.5 --- src/main/resources/static/admin.html | 907 +++++++++++++++++++++++---- 1 file changed, 799 insertions(+), 108 deletions(-) diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html index 5d5e866b..7b6f13d3 100644 --- a/src/main/resources/static/admin.html +++ b/src/main/resources/static/admin.html @@ -4,8 +4,9 @@ - Debate Timer · 라이브 공유 통계 + Debate Timer · 어드민 +
-

라이브 공유 통계

- -
+ - + + + + + + + + +
+ + - + +