All files / course course-recommendation.service.ts

100% Statements 83/83
100% Branches 18/18
100% Functions 12/12
100% Lines 77/77

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 2125x 5x 5x 5x 5x 5x 5x 5x 5x 5x 5x   5x   5x 5x 5x       5x 5x 5x       5x   15x 15x 15x 15x               9x     9x 2x       7x           7x       7x         7x 60x     21x 1x 1x   1x       1x   21x       7x     21x 21x         21x       21x 1x   20x           6x 6x 36x 18x     18x 18x 18x 18x       6x 6x             6x             6x               6x               8x 8x 2x         6x         6x 6x 6x       6x         6x 1x 1x   1x         1x       6x         6x       6x 1x   5x     5x                     5x      
import { Injectable, NotFoundException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { DEFAULT_CUSTOMS } from 'src/commons/constants/custom-place';
import { Emojis } from 'src/commons/constants/emoji';
import { ERROR } from 'src/commons/constants/error';
import { getCustomByPlaceType } from 'src/commons/helpers/custom-by-place-type.helper';
import { getPlaceTypeKey } from 'src/commons/helpers/place-type.helper';
import { getRandomShuffleElements, getTopWeight } from 'src/commons/helpers/place-weight.helper';
import { isEmpty, isNotEmpty } from 'src/commons/util/is/is-empty';
import { generateUUID } from 'src/commons/util/uuid';
import { CourseQueryRepository } from 'src/course/course.query.repository';
import { ApiCourseGetPlaceCustomizeRequestQueryDto } from 'src/course/dto/api-course-get-place-customize-request-query.dto';
import { ApiCourseGetPlaceCustomizeResponseDto } from 'src/course/dto/api-course-get-place-customize-response.dto';
import { ApiCourseGetRecommendRequestQueryDto } from 'src/course/dto/api-course-get-recommend-request-query.dto';
import { ApiCourseGetRecommendResponseDto } from 'src/course/dto/api-course-get-recommend-response.dto';
import { CoursePlaceInfoDto } from 'src/course/dto/course-place-info.dto';
import { RecommendType } from 'src/course/enum/course-recommend.enum';
import { CourseDetailEntity } from 'src/entities/course.detail.entity';
import { PlaceEntity } from 'src/entities/place.entity';
import { ThemeEntity } from 'src/entities/theme.entity';
import { PlaceQueryRepository } from 'src/place/place.query.repository';
import { SubwayQueryRepository } from 'src/subway/subway.query.repository';
import { ThemeQueryRepository } from 'src/theme/theme.query.repository';
import { UserDto } from 'src/user/dto/user.dto';
 
@Injectable()
export class CourseRecommendationService {
  constructor(
    private readonly courseQueryRepository: CourseQueryRepository,
    private readonly subwayQueryRepository: SubwayQueryRepository,
    private readonly placeQueryRepository: PlaceQueryRepository,
    private readonly themeQueryRepository: ThemeQueryRepository,
  ) {}
 
  async getCourseRecommendation(
    dto: ApiCourseGetRecommendRequestQueryDto,
    user?: UserDto,
  ): Promise<ApiCourseGetRecommendResponseDto> {
    // 1. 지하철역 및 노선 정보 조회
    const subwayWithLines = await this.subwayQueryRepository.findAllLinesForStation(
      dto.station_uuid,
    );
    if (isEmpty(subwayWithLines)) {
      throw new NotFoundException(ERROR.NOT_EXIST_DATA);
    }
 
    // 2. 주변 장소 리스트 조회
    const subwayPlaceList: PlaceEntity[] = await this.placeQueryRepository.findSubwayPlaceList(
      dto,
      subwayWithLines[0].name,
    );
 
    // 3. 테마 및 히스토리 조회 (존재할 경우)
    const theme: ThemeEntity = isNotEmpty(dto.theme_uuid)
      ? await this.themeQueryRepository.findThemeUuid(dto.theme_uuid)
      : null;
 
    const userHistoryCourse: CourseDetailEntity[] = isNotEmpty(user)
      ? await this.courseQueryRepository.findUserHistoryCourse(user.uuid)
      : [];
 
    // 4. 각 커스텀에 해당하는 장소 조회 헬퍼 함수
    const fetchPlacesByCustom = async (custom: string): Promise<PlaceEntity[]> => {
      let placesByCategory = subwayPlaceList.filter((place) => place.place_type === custom);
 
      // 해당 custom에 맞는 테마가 없을 시 재추천
      if (placesByCategory.length === 0) {
        const dtoWithoutTheme = { ...dto };
        delete dtoWithoutTheme.theme_uuid;
 
        const fallbackPlaces = await this.placeQueryRepository.findSubwayPlaceList(
          dtoWithoutTheme,
          subwayWithLines[0].name,
        );
        placesByCategory = fallbackPlaces.filter((place) => place.place_type === custom);
      }
      return placesByCategory;
    };
 
    // 5. 기본 커스텀 타입별로 가중치 계산 및 랜덤 선택, 병렬 처리
    const selectionPlaces: PlaceEntity[] = (
      await Promise.all(
        DEFAULT_CUSTOMS.map(async (custom) => {
          const customPlaces = await fetchPlacesByCustom(custom);
          const topWeightedPlaces = getTopWeight(
            customPlaces,
            RecommendType.TOP_N,
            userHistoryCourse,
          );
          const selectedPlaces = getRandomShuffleElements(
            topWeightedPlaces,
            RecommendType.RANDOM_SELECTION_COUNT,
          );
          if (isEmpty(selectedPlaces)) {
            throw new NotFoundException(ERROR.NOT_EXIST_DATA);
          }
          return selectedPlaces;
        }),
      )
    ).flat();
 
    // 6. 커스텀 순서 정렬 및 DTO 변환
    const sortedPlaces: CoursePlaceInfoDto[] = [];
    DEFAULT_CUSTOMS.forEach((custom, index) => {
      const place = selectionPlaces.find((item) => item.place_type === custom);
      const placeDetailDto = plainToInstance(CoursePlaceInfoDto, place, {
        excludeExtraneousValues: true,
      });
      placeDetailDto.sort = index + 1;
      placeDetailDto.place_type = getPlaceTypeKey(placeDetailDto.place_type);
      placeDetailDto.place_detail = getCustomByPlaceType(place, custom);
      sortedPlaces.push(placeDetailDto);
    });
 
    // 7. 코스 이름 생성
    const stationName = subwayWithLines[0].name;
    const courseName = isEmpty(theme)
      ? `${stationName}역, 주변 코스 일정 ${Emojis[Math.floor(Math.random() * Emojis.length)]}`
      : `${stationName}역, ${theme.theme_name
          .slice(0, -2)
          .trim()} 코스 일정 ${theme.theme_name.slice(-2)}`;
 
    // 7. Response 생성
    const apiCourseGetRecommendResponseDto = new ApiCourseGetRecommendResponseDto({
      course_uuid: generateUUID(),
      course_name: courseName,
      subway: {
        uuid: subwayWithLines[0].uuid,
        station: subwayWithLines[0].name,
      },
      line: subwayWithLines.map((line) => ({
        uuid: line.uuid,
        line: line.line,
      })),
      theme: theme ? { uuid: theme.uuid, theme: theme.theme_name } : undefined,
      places: sortedPlaces,
    });
 
    return apiCourseGetRecommendResponseDto;
  }
 
  async addCustomPlaceToCourse(
    dto: ApiCourseGetPlaceCustomizeRequestQueryDto,
    user?: UserDto,
  ): Promise<ApiCourseGetPlaceCustomizeResponseDto> {
    // 1. 지하철역 및 노선 정보 조회
    const subwayStation = await this.subwayQueryRepository.findSubwayStationUuid(dto.station_uuid);
    if (isEmpty(subwayStation)) {
      throw new NotFoundException(ERROR.NOT_EXIST_DATA);
    }
 
    // 2. 주변 장소 리스트 조회
    let subwayPlaceCustomizeList: PlaceEntity[] =
      dto.place_type === 'CULTURE'
        ? await this.placeQueryRepository.findSubwayPlacesCustomizeCultureList(subwayStation.name)
        : await this.placeQueryRepository.findSubwayPlacesCustomizeList(dto, subwayStation.name);
 
    // 3. 이미 선택되어 있는 장소 제외
    const placeUuidsSet = new Set(dto.place_uuids);
    let filteredPlaceList = subwayPlaceCustomizeList.filter(
      (place) => !placeUuidsSet.has(place.uuid),
    );
 
    // 4. 사용자 히스토리 기록 조회
    const userHistoryCourse: CourseDetailEntity[] = isNotEmpty(user)
      ? await this.courseQueryRepository.findUserHistoryCourse(user.uuid)
      : [];
 
    // 5. 테마에 맞는 추가 장소가 없을 시 재추천
    if (filteredPlaceList.length === 0) {
      const dtoWithoutTheme = { ...dto };
      delete dtoWithoutTheme.theme_uuid;
 
      const fallbackList = await this.placeQueryRepository.findSubwayPlacesCustomizeList(
        dtoWithoutTheme,
        subwayStation.name,
      );
 
      filteredPlaceList = fallbackList.filter((place) => !placeUuidsSet.has(place.uuid));
    }
 
    // 6. 장소 가중치 계산 및 랜덤 선택
    const topWeightedPlaces = getTopWeight(
      filteredPlaceList,
      RecommendType.TOP_N,
      userHistoryCourse,
    );
    const selected = getRandomShuffleElements(
      topWeightedPlaces,
      RecommendType.RANDOM_SELECTION_COUNT,
    );
    if (isEmpty(selected)) {
      throw new NotFoundException(ERROR.NOT_EXIST_DATA);
    }
    const selectedPlace = selected[0];
 
    // 7. Response 생성
    const apiCourseGetPlaceCustomizeResponseDto = plainToInstance(
      ApiCourseGetPlaceCustomizeResponseDto,
      {
        ...selectedPlace,
        place_detail: getCustomByPlaceType(selectedPlace, selectedPlace.place_type),
        place_type: getPlaceTypeKey(selectedPlace.place_type),
        sort: dto.place_uuids.length + 1,
      },
      { excludeExtraneousValues: true },
    );
 
    return apiCourseGetPlaceCustomizeResponseDto;
  }
}