diff --git a/.gitignore b/.gitignore index 72b65d10..a99cf048 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ out/ ### application-local.yml /src/main/resources/application-local.yml .serena + +### Uploaded icons (local) ### +uploads/ diff --git a/docker/application/docker-compose.application.yml b/docker/application/docker-compose.application.yml index 2e9ec098..c6250cbf 100644 --- a/docker/application/docker-compose.application.yml +++ b/docker/application/docker-compose.application.yml @@ -22,6 +22,10 @@ services: - MANAGEMENT_STATSD_METRICS_EXPORT_PORT=8125 - MANAGEMENT_STATSD_METRICS_EXPORT_PROTOCOL=UDP + # 어드민에서 업로드한 단체 아이콘을 배포 간에 유지한다 + volumes: + - icon-upload:/workspace/icon-upload + networks: - debate-timer-net @@ -52,3 +56,7 @@ services: - "com.datadoghq.ad.logs=[{\"source\": \"java\", \"service\": \"debate-timer\"}]" restart: unless-stopped + +volumes: + icon-upload: + name: debate-timer-icon-upload diff --git a/src/main/java/com/debatetimer/client/storage/IconFileStorage.java b/src/main/java/com/debatetimer/client/storage/IconFileStorage.java new file mode 100644 index 00000000..06837e58 --- /dev/null +++ b/src/main/java/com/debatetimer/client/storage/IconFileStorage.java @@ -0,0 +1,89 @@ +package com.debatetimer.client.storage; + +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; + +/** + * 아이콘 파일을 다룬다. 배포에 포함된 기본 아이콘(classpath:/static/icon)은 읽기만 하고, + * 업로드한 아이콘은 jar 밖의 업로드 디렉터리에 저장한다. 두 위치 모두 /icon/** 로 제공된다. + */ +@Component +public class IconFileStorage { + + private static final String BUNDLED_ICON_PATTERN = "classpath:/static/icon/*"; + + private final Path uploadDir; + private final List bundledFileNames; + + public IconFileStorage(IconStorageProperties properties) { + this.uploadDir = properties.getUploadDir(); + this.bundledFileNames = loadBundledFileNames(); + createUploadDir(); + } + + private List loadBundledFileNames() { + try { + Resource[] resources = new PathMatchingResourcePatternResolver().getResources(BUNDLED_ICON_PATTERN); + return Arrays.stream(resources) + .map(Resource::getFilename) + .filter(Objects::nonNull) + .sorted() + .toList(); + } catch (IOException exception) { + return List.of(); + } + } + + private void createUploadDir() { + try { + Files.createDirectories(uploadDir); + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } + + public List findBundledFileNames() { + return bundledFileNames; + } + + public List findUploadedFileNames() { + try (Stream files = Files.list(uploadDir)) { + return files.filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .sorted() + .toList(); + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } + + public boolean exists(String fileName) { + return bundledFileNames.contains(fileName) || Files.exists(uploadDir.resolve(fileName)); + } + + /** + * 같은 이름의 파일이 이미 있으면 덮어쓰지 않고 false를 반환한다. + */ + public boolean saveIfAbsent(String fileName, byte[] content) { + try { + Files.write(uploadDir.resolve(fileName), content, StandardOpenOption.CREATE_NEW); + return true; + } catch (FileAlreadyExistsException exception) { + return false; + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } +} diff --git a/src/main/java/com/debatetimer/client/storage/IconStorageProperties.java b/src/main/java/com/debatetimer/client/storage/IconStorageProperties.java new file mode 100644 index 00000000..db418978 --- /dev/null +++ b/src/main/java/com/debatetimer/client/storage/IconStorageProperties.java @@ -0,0 +1,25 @@ +package com.debatetimer.client.storage; + +import com.debatetimer.exception.custom.DTInitializationException; +import com.debatetimer.exception.errorcode.InitializationErrorCode; +import java.nio.file.Path; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "icon") +public class IconStorageProperties { + + private final Path uploadDir; + + public IconStorageProperties(String uploadDir) { + if (uploadDir == null || uploadDir.isBlank()) { + throw new DTInitializationException(InitializationErrorCode.ICON_UPLOAD_DIR_EMPTY); + } + this.uploadDir = Path.of(uploadDir).toAbsolutePath().normalize(); + } + + public String getResourceLocation() { + return uploadDir.toUri().toString().replaceAll("/?$", "/"); + } +} diff --git a/src/main/java/com/debatetimer/config/StorageConfig.java b/src/main/java/com/debatetimer/config/StorageConfig.java new file mode 100644 index 00000000..62a55844 --- /dev/null +++ b/src/main/java/com/debatetimer/config/StorageConfig.java @@ -0,0 +1,11 @@ +package com.debatetimer.config; + +import com.debatetimer.client.storage.IconStorageProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(IconStorageProperties.class) +public class StorageConfig { + +} diff --git a/src/main/java/com/debatetimer/config/WebConfig.java b/src/main/java/com/debatetimer/config/WebConfig.java index 0ce8ab1e..7cbec365 100644 --- a/src/main/java/com/debatetimer/config/WebConfig.java +++ b/src/main/java/com/debatetimer/config/WebConfig.java @@ -1,11 +1,14 @@ package com.debatetimer.config; +import com.debatetimer.client.storage.IconStorageProperties; import com.debatetimer.controller.tool.jwt.AuthManager; +import com.debatetimer.domain.organization.OrganizationIcon; import com.debatetimer.service.auth.AuthService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @@ -14,9 +17,17 @@ public class WebConfig implements WebMvcConfigurer { private final AuthManager authManager; private final AuthService authService; + private final IconStorageProperties iconStorageProperties; @Override public void addArgumentResolvers(List argumentResolvers) { argumentResolvers.add(new AuthMemberArgumentResolver(authManager, authService)); } + + // 업로드한 아이콘을 먼저 찾고, 없으면 배포에 포함된 기본 아이콘을 찾는다 + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler(OrganizationIcon.PATH_PREFIX + "**") + .addResourceLocations(iconStorageProperties.getResourceLocation(), "classpath:/static/icon/"); + } } diff --git a/src/main/java/com/debatetimer/controller/admin/AdminIconController.java b/src/main/java/com/debatetimer/controller/admin/AdminIconController.java new file mode 100644 index 00000000..6b3003ad --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminIconController.java @@ -0,0 +1,56 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.dto.admin.IconResponse; +import com.debatetimer.dto.admin.IconResponses; +import com.debatetimer.exception.custom.DTServerErrorException; +import com.debatetimer.exception.errorcode.ServerErrorCode; +import com.debatetimer.service.admin.AdminIconService; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +public class AdminIconController { + + private final AdminAuthorizer adminAuthorizer; + private final AdminIconService adminIconService; + + @GetMapping("/api/admin/icons") + @ResponseStatus(HttpStatus.OK) + public IconResponses getIcons( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password + ) { + adminAuthorizer.authorize(password); + return adminIconService.findAll(); + } + + @PostMapping(value = "/api/admin/icons", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public IconResponse uploadIcon( + @RequestHeader(name = AdminSharingController.ADMIN_PASSWORD_HEADER, required = false) String password, + @RequestPart("file") MultipartFile file, + @RequestParam(name = "name", required = false) String name + ) { + adminAuthorizer.authorize(password); + String fileName = (name == null || name.isBlank()) ? file.getOriginalFilename() : name; + return adminIconService.upload(fileName, readBytes(file)); + } + + private byte[] readBytes(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException exception) { + throw new DTServerErrorException(ServerErrorCode.ICON_STORAGE_ERROR); + } + } +} 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/controller/admin/AdminStatsController.java b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java new file mode 100644 index 00000000..56fd3761 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminStatsController.java @@ -0,0 +1,49 @@ +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; +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; + private final MemberSignupStatsService memberSignupStatsService; + + @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); + } + + @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/controller/admin/AdminTableController.java b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java new file mode 100644 index 00000000..7322b665 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/admin/AdminTableController.java @@ -0,0 +1,44 @@ +package com.debatetimer.controller.admin; + +import com.debatetimer.domain.customize.CustomizeTableSortType; +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 = "sort", defaultValue = "CREATED_AT") CustomizeTableSortType sort, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "20") int size + ) { + adminAuthorizer.authorize(password); + return adminTableService.getTables(sort, 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/domain/customize/CustomizeTableSortType.java b/src/main/java/com/debatetimer/domain/customize/CustomizeTableSortType.java new file mode 100644 index 00000000..0082ca85 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/customize/CustomizeTableSortType.java @@ -0,0 +1,11 @@ +package com.debatetimer.domain.customize; + +/** + * 어드민 테이블 목록의 정렬 기준. 모두 최신순(내림차순)으로 정렬한다. + */ +public enum CustomizeTableSortType { + + CREATED_AT, + USED_AT, + ; +} diff --git a/src/main/java/com/debatetimer/domain/organization/IconImageType.java b/src/main/java/com/debatetimer/domain/organization/IconImageType.java new file mode 100644 index 00000000..3d48cc45 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/IconImageType.java @@ -0,0 +1,49 @@ +package com.debatetimer.domain.organization; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.Arrays; +import java.util.function.Predicate; + +/** + * 업로드할 수 있는 아이콘 이미지 형식. 확장자 대신 파일 앞부분의 시그니처로 형식을 판별한다. + * 스크립트를 담을 수 있는 SVG는 허용하지 않는다. + */ +public enum IconImageType { + + PNG("png", content -> startsWith(content, 0, new byte[]{(byte) 0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'})), + JPEG("jpg", content -> startsWith(content, 0, new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF})), + WEBP("webp", content -> startsWith(content, 0, "RIFF".getBytes()) && startsWith(content, 8, "WEBP".getBytes())), + ; + + private final String extension; + private final Predicate signatureMatcher; + + IconImageType(String extension, Predicate signatureMatcher) { + this.extension = extension; + this.signatureMatcher = signatureMatcher; + } + + public static IconImageType from(byte[] content) { + return Arrays.stream(values()) + .filter(type -> type.signatureMatcher.test(content)) + .findFirst() + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.INVALID_ICON_FILE)); + } + + private static boolean startsWith(byte[] content, int offset, byte[] signature) { + if (content.length < offset + signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if (content[offset + i] != signature[i]) { + return false; + } + } + return true; + } + + public String getExtension() { + return extension; + } +} 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/OrganizationIcon.java b/src/main/java/com/debatetimer/domain/organization/OrganizationIcon.java new file mode 100644 index 00000000..70a2e821 --- /dev/null +++ b/src/main/java/com/debatetimer/domain/organization/OrganizationIcon.java @@ -0,0 +1,53 @@ +package com.debatetimer.domain.organization; + +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.Locale; +import java.util.regex.Pattern; +import lombok.Getter; + +/** + * 단체 아이콘 이미지. 파일 이름은 경로 조작을 막기 위해 영문 소문자, 숫자, '_', '-'만 쓰고 확장자는 이미지 형식으로 정한다. + */ +@Getter +public class OrganizationIcon { + + public static final String PATH_PREFIX = "/icon/"; + public static final int MAX_SIZE_BYTES = 2 * 1024 * 1024; + public static final int MAX_NAME_LENGTH = 50; + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9_-]{1,%d}$".formatted(MAX_NAME_LENGTH)); + private static final Pattern EXTENSION_PATTERN = Pattern.compile("\\.[A-Za-z0-9]+$"); + + private final String fileName; + private final byte[] content; + + public OrganizationIcon(String name, byte[] content) { + validateSize(content); + IconImageType type = IconImageType.from(content); + this.fileName = normalizeName(name) + "." + type.getExtension(); + this.content = content; + } + + private void validateSize(byte[] content) { + if (content == null || content.length == 0 || content.length > MAX_SIZE_BYTES) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ICON_FILE); + } + } + + private String normalizeName(String name) { + if (name == null) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ICON_NAME); + } + String normalized = EXTENSION_PATTERN.matcher(name.strip()).replaceFirst("") + .toLowerCase(Locale.ROOT) + .replaceAll("\\s+", "_"); + if (!NAME_PATTERN.matcher(normalized).matches()) { + throw new DTClientErrorException(ClientErrorCode.INVALID_ICON_NAME); + } + return normalized; + } + + public String getPath() { + return PATH_PREFIX + fileName; + } +} 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/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..440e58ab 100644 --- a/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/customize/CustomizeTableDomainRepository.java @@ -1,6 +1,8 @@ package com.debatetimer.domainrepository.customize; import com.debatetimer.domain.customize.CustomizeTable; +import com.debatetimer.domain.customize.CustomizeTableOverview; +import com.debatetimer.domain.customize.CustomizeTableSortType; import com.debatetimer.domain.customize.CustomizeTimeBox; import com.debatetimer.domain.member.Member; import com.debatetimer.entity.customize.BellEntity; @@ -9,6 +11,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; @@ -16,6 +19,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; @@ -65,6 +72,26 @@ public CustomizeTable getWithMemberById(long tableId) { .toDomain(); } + @Transactional(readOnly = true) + public CustomizeTableOverview getOverviewById(long tableId) { + return tableRepository.getWithMemberById(tableId) + .toOverview(); + } + + @Transactional(readOnly = true) + public Page findOverviews(CustomizeTableSortType sortType, int page, int size) { + return tableRepository.findAllBy(PageRequest.of(page, size, toLatestFirstSort(sortType))) + .map(CustomizeTableEntity::toOverview); + } + + private Sort toLatestFirstSort(CustomizeTableSortType sortType) { + String property = switch (sortType) { + case CREATED_AT -> "createdAt"; + case USED_AT -> "usedAt"; + }; + return Sort.by(Direction.DESC, property, "id"); + } + @Transactional(readOnly = true) public List getMemberCustomizeTimeBoxes(long tableId, Member member) { CustomizeTableEntity tableEntity = tableRepository.getByIdAndMember(tableId, member); @@ -91,6 +118,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/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/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/domainrepository/organization/OrganizationIconDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java new file mode 100644 index 00000000..a2c48260 --- /dev/null +++ b/src/main/java/com/debatetimer/domainrepository/organization/OrganizationIconDomainRepository.java @@ -0,0 +1,38 @@ +package com.debatetimer.domainrepository.organization; + +import com.debatetimer.client.storage.IconFileStorage; +import com.debatetimer.domain.organization.OrganizationIcon; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class OrganizationIconDomainRepository { + + private final IconFileStorage iconFileStorage; + + public List findBundledPaths() { + return toPaths(iconFileStorage.findBundledFileNames()); + } + + public List findUploadedPaths() { + return toPaths(iconFileStorage.findUploadedFileNames()); + } + + private List toPaths(List fileNames) { + return fileNames.stream() + .map(fileName -> OrganizationIcon.PATH_PREFIX + fileName) + .toList(); + } + + public String save(OrganizationIcon icon) { + if (iconFileStorage.exists(icon.getFileName()) + || !iconFileStorage.saveIfAbsent(icon.getFileName(), icon.getContent())) { + throw new DTClientErrorException(ClientErrorCode.ICON_ALREADY_EXISTS); + } + return icon.getPath(); + } +} diff --git a/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java index 861dcc5f..71885ba5 100644 --- a/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/sharing/SharingLogDomainRepository.java @@ -30,9 +30,9 @@ public SharingLog getById(long id) { } @Transactional(readOnly = true) - public List findAllFinishedStartedBetween(LocalDateTime from, LocalDateTime to) { - return sharingLogRepository.findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLessThan( - SharingLogStatus.FINISHED, from, to) + public List findAllEndedStartedBetween(LocalDateTime from, LocalDateTime to) { + return sharingLogRepository.findAllByStatusInAndStartedAtGreaterThanEqualAndStartedAtLessThan( + List.of(SharingLogStatus.FINISHED, SharingLogStatus.ABANDONED), from, to) .stream() .map(SharingLogEntity::toDomain) .toList(); 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/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/dto/admin/IconResponse.java b/src/main/java/com/debatetimer/dto/admin/IconResponse.java new file mode 100644 index 00000000..3bc1a6da --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/IconResponse.java @@ -0,0 +1,4 @@ +package com.debatetimer.dto.admin; + +public record IconResponse(String path, boolean uploaded) { +} diff --git a/src/main/java/com/debatetimer/dto/admin/IconResponses.java b/src/main/java/com/debatetimer/dto/admin/IconResponses.java new file mode 100644 index 00000000..e19b3771 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/admin/IconResponses.java @@ -0,0 +1,6 @@ +package com.debatetimer.dto.admin; + +import java.util.List; + +public record IconResponses(List icons) { +} 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/dto/admin/SharingDailyStatResponse.java b/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java index 34e152c7..fa727187 100644 --- a/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java +++ b/src/main/java/com/debatetimer/dto/admin/SharingDailyStatResponse.java @@ -5,6 +5,8 @@ public record SharingDailyStatResponse( LocalDate date, int finishedCount, - double averageMinutes + double averageMinutes, + int abandonedCount, + double abandonedAverageMinutes ) { } 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/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/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/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 728bf014..cdf294ee 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -4,7 +4,10 @@ import com.debatetimer.domain.customize.Bell; import com.debatetimer.domain.customize.TableName; import com.debatetimer.domain.customize.TeamName; +import com.debatetimer.domain.organization.OrganizationIcon; +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; @@ -65,6 +68,12 @@ public enum ClientErrorCode implements ResponseErrorCode { EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "토큰 기한이 만료되었습니다"), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원이 존재하지 않습니다"), + ORGANIZATION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 단체가 존재하지 않습니다"), + INVALID_ICON_FILE(HttpStatus.BAD_REQUEST, "아이콘은 PNG, JPEG, WEBP 이미지만 %dMB 이하로 업로드할 수 있습니다" + .formatted(OrganizationIcon.MAX_SIZE_BYTES / 1024 / 1024)), + INVALID_ICON_NAME(HttpStatus.BAD_REQUEST, "아이콘 이름은 영문 소문자, 숫자, '_', '-'로 %d자 이하여야 합니다" + .formatted(OrganizationIcon.MAX_NAME_LENGTH)), + ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "같은 이름의 아이콘이 이미 있습니다"), INVALID_OAUTH_REQUEST(HttpStatus.BAD_REQUEST, "구글 OAuth 요청 파라미터 값이 잘못되었습니다."), @@ -89,6 +98,10 @@ 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)), + 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/exception/errorcode/InitializationErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java index 2eb249a6..65f4a215 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/InitializationErrorCode.java @@ -19,6 +19,7 @@ public enum InitializationErrorCode { JWT_TOKEN_DURATION_INVALID("토큰 만료 기간은 양수이어야 합니다"), ADMIN_PASSWORD_EMPTY("어드민 비밀번호가 입력되지 않았습니다"), + ICON_UPLOAD_DIR_EMPTY("아이콘 업로드 디렉터리가 입력되지 않았습니다"), ; private final String message; diff --git a/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java index 1bd205f5..2f5e87cc 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ServerErrorCode.java @@ -8,7 +8,8 @@ public enum ServerErrorCode implements ResponseErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다. 관리자에게 문의하세요."), EXCEL_EXPORT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "엑셀 변환 과정에서 오류가 발생하였습니다"), - OAUTH_REQUEST_FAILED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 OAuth 요청 과정에 오류가 발생하였습니다"); + OAUTH_REQUEST_FAILED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "구글 OAuth 요청 과정에 오류가 발생하였습니다"), + ICON_STORAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "아이콘 파일 저장소 처리 중 오류가 발생하였습니다"); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java index 5e320257..409accdf 100644 --- a/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java +++ b/src/main/java/com/debatetimer/repository/customize/CustomizeTableRepository.java @@ -4,9 +4,13 @@ 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.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; public interface CustomizeTableRepository extends Repository { @@ -38,5 +42,14 @@ 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); + + @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/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/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/repository/sharing/SharingLogRepository.java b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java index a4da9b2d..13d05176 100644 --- a/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java +++ b/src/main/java/com/debatetimer/repository/sharing/SharingLogRepository.java @@ -5,6 +5,7 @@ import com.debatetimer.exception.custom.DTClientErrorException; import com.debatetimer.exception.errorcode.ClientErrorCode; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Modifying; @@ -20,8 +21,8 @@ public interface SharingLogRepository extends Repository List findAll(); - List findAllByStatusAndStartedAtGreaterThanEqualAndStartedAtLessThan( - SharingLogStatus status, + List findAllByStatusInAndStartedAtGreaterThanEqualAndStartedAtLessThan( + Collection statuses, LocalDateTime from, LocalDateTime to ); diff --git a/src/main/java/com/debatetimer/service/admin/AdminIconService.java b/src/main/java/com/debatetimer/service/admin/AdminIconService.java new file mode 100644 index 00000000..bb5be337 --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/AdminIconService.java @@ -0,0 +1,30 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.organization.OrganizationIcon; +import com.debatetimer.domainrepository.organization.OrganizationIconDomainRepository; +import com.debatetimer.dto.admin.IconResponse; +import com.debatetimer.dto.admin.IconResponses; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminIconService { + + private final OrganizationIconDomainRepository organizationIconDomainRepository; + + public IconResponses findAll() { + return new IconResponses(Stream.concat( + organizationIconDomainRepository.findBundledPaths().stream() + .map(path -> new IconResponse(path, false)), + organizationIconDomainRepository.findUploadedPaths().stream() + .map(path -> new IconResponse(path, true)) + ).toList()); + } + + public IconResponse upload(String name, byte[] content) { + String path = organizationIconDomainRepository.save(new OrganizationIcon(name, content)); + return new IconResponse(path, true); + } +} 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/main/java/com/debatetimer/service/admin/AdminTableService.java b/src/main/java/com/debatetimer/service/admin/AdminTableService.java new file mode 100644 index 00000000..d5299fd7 --- /dev/null +++ b/src/main/java/com/debatetimer/service/admin/AdminTableService.java @@ -0,0 +1,43 @@ +package com.debatetimer.service.admin; + +import com.debatetimer.domain.customize.CustomizeTableOverview; +import com.debatetimer.domain.customize.CustomizeTableSortType; +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(CustomizeTableSortType sortType, int page, int size) { + validatePage(page, size); + return AdminTablePageResponse.from( + customizeTableDomainRepository.findOverviews(sortType, 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/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/main/java/com/debatetimer/service/admin/SharingStatsService.java b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java index 500d6050..6fc83e28 100644 --- a/src/main/java/com/debatetimer/service/admin/SharingStatsService.java +++ b/src/main/java/com/debatetimer/service/admin/SharingStatsService.java @@ -1,6 +1,7 @@ package com.debatetimer.service.admin; import com.debatetimer.domain.sharing.SharingLog; +import com.debatetimer.domain.sharing.SharingLogStatus; import com.debatetimer.domainrepository.sharing.SharingLogDomainRepository; import com.debatetimer.dto.admin.SharingDailyStatResponse; import com.debatetimer.dto.admin.SharingStatsResponse; @@ -15,7 +16,7 @@ import org.springframework.stereotype.Service; /** - * 종료(FINISHED)된 공유를 시작일 기준으로 모아 일별 횟수와 평균 공유 시간을 계산한다. + * 종료(FINISHED)되거나 중단(ABANDONED)된 공유를 시작일 기준으로 모아 상태별 일별 횟수와 평균 공유 시간을 계산한다. */ @Service @RequiredArgsConstructor @@ -29,7 +30,7 @@ public class SharingStatsService { public SharingStatsResponse getDailyStats(LocalDate from, LocalDate to) { validatePeriod(from, to); Map> logsByDate = sharingLogDomainRepository - .findAllFinishedStartedBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()) + .findAllEndedStartedBetween(from.atStartOfDay(), to.plusDays(1).atStartOfDay()) .stream() .collect(Collectors.groupingBy(sharingLog -> sharingLog.getStartedAt().toLocalDate())); @@ -46,10 +47,28 @@ private void validatePeriod(LocalDate from, LocalDate to) { } private SharingDailyStatResponse toDailyStat(LocalDate date, List logs) { + List finished = filterByStatus(logs, SharingLogStatus.FINISHED); + List abandoned = filterByStatus(logs, SharingLogStatus.ABANDONED); + return new SharingDailyStatResponse( + date, + finished.size(), + calculateAverageMinutes(finished), + abandoned.size(), + calculateAverageMinutes(abandoned) + ); + } + + private List filterByStatus(List logs, SharingLogStatus status) { + return logs.stream() + .filter(sharingLog -> sharingLog.getStatus() == status) + .toList(); + } + + private double calculateAverageMinutes(List logs) { double averageMinutes = logs.stream() .mapToLong(SharingLog::getDurationSeconds) .average() .orElse(0) / SECONDS_PER_MINUTE; - return new SharingDailyStatResponse(date, logs.size(), Math.round(averageMinutes * 10) / 10.0); + return Math.round(averageMinutes * 10) / 10.0; } } 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/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 45350ab8..b38b09f2 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -43,5 +43,8 @@ discord: admin: password: ${secret.admin.password} +icon: + upload-dir: /workspace/icon-upload + logging: config: classpath:logging/log4j2-dev.yml diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ffd17fd0..a110f85d 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -42,5 +42,8 @@ discord: admin: password: ${secret.admin.password} +icon: + upload-dir: /workspace/icon-upload + logging: config: classpath:logging/log4j2-prod.yml diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8196d857..5c5e5bcc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,18 @@ spring: default: local lifecycle: timeout-per-shutdown-phase: 60s + servlet: + multipart: + max-file-size: 2MB + max-request-size: 3MB server: shutdown: graceful +# 어드민 아이콘 업로드 위치. dev/prod는 도커 볼륨 경로로 덮어쓴다 +icon: + upload-dir: ${ICON_UPLOAD_DIR:./uploads/icon} + springdoc: swagger-ui: path: /docs/swagger diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html index 5d5e866b..c65581c6 100644 --- a/src/main/resources/static/admin.html +++ b/src/main/resources/static/admin.html @@ -4,8 +4,13 @@ - Debate Timer · 라이브 공유 통계 - + Debate Timer · 어드민 + +
-

라이브 공유 통계

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