Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 60 additions & 60 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,60 +1,60 @@
#name: Clokey CI
#
#on:
# pull_request:
# branches: [ "main", "develop" ]
# paths:
# - "clokey-api/**"
# - "clokey-domain/**"
# - "clokey-common/**"
# - "clokey-common-web/**"
# - ".github/workflows/ci.yml"
#
#permissions:
# contents: read
#
#jobs:
# build:
# runs-on: ubuntu-latest
#
# steps:
# - uses: actions/checkout@v4
# with:
# clean: true
#
# - name: Setup Java 21
# uses: actions/setup-java@v4
# with:
# distribution: 'corretto'
# java-version: '21'
#
# - name: Start Redis container for test
# run: docker compose -f ./docker-compose-test.yml up -d
#
# - name: Gradle Caching
# uses: actions/cache@v4
# with:
# path: |
# ~/.gradle/caches
# ~/.gradle/wrapper
# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
# restore-keys: ${{ runner.os }}-gradle-
#
# - name: Grant execute permission for gradlew
# run: chmod +x ./gradlew
#
# - name: Spotless Check
# run: ./gradlew spotlessCheck
#
# - name: SonarCloud Caching
# uses: actions/cache@v4
# with:
# path: ~/.sonar/cache
# key: ${{ runner.os }}-sonar
# restore-keys: ${{ runner.os }}-sonar
#
# - name: Test and Analyze
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# run: ./gradlew test sonar --info --stacktrace
name: Clokey CI

on:
pull_request:
branches: [ "main", "develop" ]
paths:
- "clokey-api/**"
- "clokey-domain/**"
- "clokey-common/**"
- "clokey-common-web/**"
- ".github/workflows/ci.yml"

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
clean: true

- name: Setup Java 21
uses: actions/setup-java@v4
with:
distribution: 'corretto'
java-version: '21'

- name: Start Redis container for test
run: docker compose -f ./docker-compose-test.yml up -d

- name: Gradle Caching
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: ${{ runner.os }}-gradle-

- name: Grant execute permission for gradlew
run: chmod +x ./gradlew

- name: Spotless Check
run: ./gradlew spotlessCheck

- name: SonarCloud Caching
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar

- name: Test and Analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew test sonar --info --stacktrace
2 changes: 1 addition & 1 deletion .github/workflows/dev-cd.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Clokey-Dev CD

on:
pull_request:
push:
branches: [ develop ]

jobs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
import org.clokey.cloth.entity.Cloth;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ClothRepository extends JpaRepository<Cloth, Long>, ClothRepositoryCustom {}
public interface ClothRepository extends JpaRepository<Cloth, Long>, ClothRepositoryCustom {
boolean existsByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ default boolean existsDailyCoordinateByDateAndMemberId(LocalDate date, Long memb

@Query("select c from Coordinate c where c.member.id = :memberId and c.liked = true")
List<Coordinate> findLikedCoordinatesByMemberId(Long memberId);

boolean existsByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
import org.clokey.history.entity.History;
import org.springframework.data.jpa.repository.JpaRepository;

public interface HistoryRepository extends JpaRepository<History, Long> {}
public interface HistoryRepository extends JpaRepository<History, Long> {
boolean existsByMemberId(Long memberId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package org.clokey.domain.statistics.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.clokey.cloth.enums.Season;
import org.clokey.code.GlobalBaseSuccessCode;
import org.clokey.domain.statistics.dto.response.ClosetUtilizationResponse;
import org.clokey.domain.statistics.dto.response.FavoriteCategoryItemsResponse;
import org.clokey.domain.statistics.dto.response.FavoriteItemsResponse;
import org.clokey.domain.statistics.dto.response.StatisticsCheckConditionResponse;
import org.clokey.domain.statistics.service.StatisticsService;
import org.clokey.response.BaseResponse;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
* TODO: 앞으로, 운영이 활성화 되고 트래픽이 많아지게 되면 해당 API의 성능에 대한 점검이 필요합니다. 통계 테이블을 따로 분리하거나, Redis를 통한 캐싱을 고려해
* 보세요.
*/
@RestController
@RequestMapping("/statistics")
@RequiredArgsConstructor
@Tag(name = "16. 통계 API", description = "통계 관련 API입니다.")
@Validated
public class StatisticsController {

private final StatisticsService statisticsService;

@GetMapping("/check-conditions")
@Operation(summary = "통계 최소 조건 확인", description = "통계 집계가 가능한 최소 조건을 확인하는 API입니다.")
public BaseResponse<StatisticsCheckConditionResponse> checkStatisticsCondition() {
StatisticsCheckConditionResponse response = statisticsService.checkStatisticsCondition();
return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response);
}

@GetMapping("/favorite-category-items")
@Operation(summary = "카테고리별 최애 아이템 조회", description = "카테고리별 아이템의 개수와 점유율을 조회하는 API입니다..")
public BaseResponse<FavoriteCategoryItemsResponse> getFavoriteCategoryItems(
@Parameter(description = "카테고리 ID") @RequestParam Long categoryId) {
FavoriteCategoryItemsResponse response =
statisticsService.getFavoriteCategoryItems(categoryId);
return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response);
}

@GetMapping("/favorite-items")
@Operation(summary = "옷장 아이템 통계 조회", description = "옷장 아이템 통계를 조회합니다.")
public BaseResponse<FavoriteItemsResponse> getFavoriteItems() {
FavoriteItemsResponse response = statisticsService.getFavoriteItems();
return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response);
}

@GetMapping("/closet-utilization")
@Operation(
summary = "옷장 활용도 조회",
description =
"시즌별 옷장 활용도를 조회합니다. HistoryClothTag에 태그되었거나 Daily Coordinate에 포함된 옷을 활용된 것으로 간주합니다.")
public BaseResponse<ClosetUtilizationResponse> getClosetUtilization(
@Parameter(description = "시즌", example = "SPRING") @RequestParam Season season) {
ClosetUtilizationResponse response = statisticsService.getClosetUtilization(season);
return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.clokey.domain.statistics.dto;

/** 1차 카테고리에 속한 옷들을 집계하기 위한 통계용 DTO입니다. */
public record CategoryCountDto(Long categoryId, String categoryName, Long count) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.clokey.domain.statistics.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record ClosetUtilizationResponse(
@Schema(description = "활용된 옷의 개수", example = "15") Long utilizedCount,
@Schema(description = "활용되지 않은 옷의 개수", example = "10") Long unutilizedCount,
@Schema(description = "활용된 옷 목록") List<ClosetUtilizationResponse.Payload> utilizedClothes,
@Schema(description = "활용되지 않은 옷 목록")
List<ClosetUtilizationResponse.Payload> unutilizedClothes) {
public static ClosetUtilizationResponse of(
Long utilizedCount,
Long unutilizedCount,
List<Payload> utilizedClothes,
List<Payload> unutilizedClothes) {
return new ClosetUtilizationResponse(
utilizedCount, unutilizedCount, utilizedClothes, unutilizedClothes);
}

@Schema(name = "ClosetUtilizationResponsePayload", description = "옷 정보")
public record Payload(
@Schema(description = "옷 이미지 URL", example = "https://example.com/image.jpg")
String imageUrl,
@Schema(description = "옷 이름", example = "맨투맨") String name,
@Schema(description = "브랜드", example = "나이키") String brand) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.clokey.domain.statistics.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record FavoriteCategoryItemsResponse(
@Schema(description = "카테고리별 최애 아이템 목록")
List<FavoriteCategoryItemsResponse.Payload> payloads) {
public static FavoriteCategoryItemsResponse of(List<Payload> payloads) {
return new FavoriteCategoryItemsResponse(payloads);
}

@Schema(name = "FavoriteCategoryItemsResponsePayload", description = "카테고리별 최애 아이템 정보")
public record Payload(
@Schema(description = "2차 카테고리 ID (기타의 경우 null)", example = "10") Long categoryId,
@Schema(description = "2차 카테고리 이름", example = "맨투맨") String categoryName,
@Schema(description = "점유율", example = "0.35") Double occupancyRate,
@Schema(description = "옷의 개수", example = "15") Long clothCount) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package org.clokey.domain.statistics.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

public record FavoriteItemsResponse(
@Schema(description = "카테고리별 최애 아이템 목록") List<FavoriteItemsResponse.Payload> payloads) {
public static FavoriteItemsResponse of(List<Payload> payloads) {
return new FavoriteItemsResponse(payloads);
}

@Schema(name = "FavoriteItemsResponsePayload", description = "카테고리별 최애 아이템 정보")
public record Payload(
@Schema(description = "2차 카테고리 ID", example = "10") Long categoryId,
@Schema(description = "2차 카테고리 이름", example = "맨투맨") String categoryName,
@Schema(description = "옷의 개수", example = "15") Long clothCount) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.clokey.domain.statistics.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

public record StatisticsCheckConditionResponse(
@Schema(description = "통계 집계 가능 여부", example = "true") boolean canAggregate) {
public static StatisticsCheckConditionResponse of(boolean canAggregate) {
return new StatisticsCheckConditionResponse(canAggregate);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.clokey.domain.statistics.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.clokey.dto.ErrorReasonDto;
import org.clokey.exception.BaseErrorCode;

@Getter
@AllArgsConstructor
public enum StatisticsErrorCode implements BaseErrorCode {
NOT_PARENT_CATEGORY(400, "STATISTICS_4001", "1차 카테고리가 아닙니다.");

private final int status;
private final String code;
private final String message;

@Override
public ErrorReasonDto getErrorReason() {
return ErrorReasonDto.of(status, code, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package org.clokey.domain.statistics.repository;

import java.util.List;
import java.util.Set;
import org.clokey.cloth.enums.Season;
import org.clokey.domain.statistics.dto.CategoryCountDto;

public interface StatisticsRepositoryCustom {
List<CategoryCountDto> countClothesByChildCategories(Long memberId, Long parentCategoryId);

List<CategoryCountDto> countClothesByCategoriesTopN(Long memberId, int limit);

long countClothesBySeason(Long memberId, Season season);

Set<Long> findUtilizedClothIds(Long memberId, Season season);

List<org.clokey.cloth.entity.Cloth> findAllClothesBySeason(Long memberId, Season season);
}
Loading