From 01ddd6786dae42039dcc1339b181e19e81937d9a Mon Sep 17 00:00:00 2001 From: Daisy Date: Wed, 31 Jul 2024 11:27:55 +0900 Subject: [PATCH] Feat/category (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: Add categoryIconMapping entity * Feat: Add categoryIconCodeMapping logic - 장소의 카테고리를 기준으로 노출할 icon을 매핑하는 로직 추가 - category_icon_mapping 테이블에 데이터를 저장해 이용함 - 매번 테이블을 조회하기에는 불필요한 데이터로 생각됨. 추후 인메모리캐시 혹은 redis 등 캐싱을 고려해야 할듯 * Fix: Edit category_icon_code type - 숫자로 저장되도록 수정 --- src/common/constants.ts | 1 + src/entities/category-icon-mapping.entity.ts | 28 ++++++ .../category-icon-mapping.repository.ts | 4 + src/entities/index.ts | 5 ++ src/entities/kakao-place.entity.ts | 8 +- src/migrations/.snapshot-korrk-stage.json | 89 ++++++++++++++++++- src/migrations/Migration20240727070747.ts | 13 +++ src/migrations/Migration20240727071134.ts | 21 +++++ src/migrations/Migration20240729140952.ts | 16 ++++ src/migrations/Migration20240729152526.ts | 21 +++++ src/place/place.controller.ts | 1 + .../dtos/searched-place-response.dto.ts | 7 +- src/search/kakao-map.types.ts | 1 + src/search/search.module.ts | 4 +- src/search/search.service.ts | 67 ++++++++++++-- src/util/util.service.ts | 9 ++ 16 files changed, 285 insertions(+), 10 deletions(-) create mode 100644 src/entities/category-icon-mapping.entity.ts create mode 100644 src/entities/category-icon-mapping.repository.ts create mode 100644 src/migrations/Migration20240727070747.ts create mode 100644 src/migrations/Migration20240727071134.ts create mode 100644 src/migrations/Migration20240729140952.ts create mode 100644 src/migrations/Migration20240729152526.ts diff --git a/src/common/constants.ts b/src/common/constants.ts index b78b603..9c15da3 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -4,3 +4,4 @@ export const IS_PROD = process.env.NODE_ENV === 'production'; export const SEARCH_KEYWORD_MAX_LENGTH = 10; export const INVITE_LINK_EXPIRATION_DAYS = 7; export const INVITE_LINK_PREVIEW_LENGTH = 6; +export const DEFAULT_CATEGORY_ICON_CODE = 100; diff --git a/src/entities/category-icon-mapping.entity.ts b/src/entities/category-icon-mapping.entity.ts new file mode 100644 index 0000000..1bdc8e1 --- /dev/null +++ b/src/entities/category-icon-mapping.entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + EntityRepositoryType, + PrimaryKey, + Property, +} from '@mikro-orm/core'; + +import { CategoryIconMappingRepository } from 'src/entities/category-icon-mapping.repository'; + +@Entity({ repository: () => CategoryIconMappingRepository }) +export class CategoryIconMapping { + @PrimaryKey({ autoincrement: true }) + id: number; + + @Property() + kakaoCategory: string; + + @Property() + categoryGroup: string; + + @Property() + iconCode: number; + + @Property({ defaultRaw: 'CURRENT_TIMESTAMP' }) + createdAt: Date = new Date(); + + [EntityRepositoryType]: CategoryIconMappingRepository; +} diff --git a/src/entities/category-icon-mapping.repository.ts b/src/entities/category-icon-mapping.repository.ts new file mode 100644 index 0000000..bba36db --- /dev/null +++ b/src/entities/category-icon-mapping.repository.ts @@ -0,0 +1,4 @@ +import { ExtendedEntityRepository } from 'src/common/helper/extended-repository.helper'; +import { CategoryIconMapping } from 'src/entities/category-icon-mapping.entity'; + +export class CategoryIconMappingRepository extends ExtendedEntityRepository {} diff --git a/src/entities/index.ts b/src/entities/index.ts index 1e17ff3..2b83cbc 100644 --- a/src/entities/index.ts +++ b/src/entities/index.ts @@ -1,3 +1,4 @@ +import { CategoryIconMapping } from 'src/entities/category-icon-mapping.entity'; import { Tag } from 'src/entities/tag.entity'; import { GroupMap } from './group-map.entity'; @@ -36,6 +37,9 @@ export * from './tag.repository'; export * from './tag-icon.entity'; export * from './tag-icon.repository'; +export * from './category-icon-mapping.entity'; +export * from './category-icon-mapping.repository'; + export const entities = [ User, GroupMap, @@ -46,4 +50,5 @@ export const entities = [ InviteLink, TagIcon, Tag, + CategoryIconMapping, ]; diff --git a/src/entities/kakao-place.entity.ts b/src/entities/kakao-place.entity.ts index 565e7d6..55069d2 100644 --- a/src/entities/kakao-place.entity.ts +++ b/src/entities/kakao-place.entity.ts @@ -32,10 +32,16 @@ export class KakaoPlace { @Property({ type: 'string', - comment: '카카오맵 basicInfo.category.cate1name', + comment: '카카오맵 basicInfo.category.catename', }) category: string; + @Property({ + comment: '카카오맵 basicInfo.category.catename을 기준으로 매핑한 icon_code', + nullable: true, + }) + categoryIconCode: number; + @Property({ type: 'string', comment: '카카오맵 basicInfo.address.newaddr.newaddrfull', diff --git a/src/migrations/.snapshot-korrk-stage.json b/src/migrations/.snapshot-korrk-stage.json index 4d5d39b..3761bf3 100644 --- a/src/migrations/.snapshot-korrk-stage.json +++ b/src/migrations/.snapshot-korrk-stage.json @@ -2,6 +2,83 @@ "namespaces": ["public"], "name": "public", "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "serial", + "unsigned": true, + "autoincrement": true, + "primary": true, + "nullable": false, + "mappedType": "integer" + }, + "kakao_category": { + "name": "kakao_category", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "category_group": { + "name": "category_group", + "type": "varchar(255)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "string" + }, + "icon_code": { + "name": "icon_code", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "integer" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "CURRENT_TIMESTAMP", + "mappedType": "datetime" + } + }, + "name": "category_icon_mapping", + "schema": "public", + "indexes": [ + { + "keyName": "category_icon_mapping_pkey", + "columnNames": ["id"], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": { + "user_provider": { + "name": "user_provider", + "schema": "public", + "items": ["KAKAO"] + }, + "user_role": { + "name": "user_role", + "schema": "public", + "items": ["USER", "ADMIN"] + } + } + }, { "columns": { "id": { @@ -31,9 +108,19 @@ "autoincrement": false, "primary": false, "nullable": false, - "comment": "카카오맵 basicInfo.category.cate1name", + "comment": "카카오맵 basicInfo.category.catename", "mappedType": "string" }, + "category_icon_code": { + "name": "category_icon_code", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "comment": "카카오맵 basicInfo.category.catename을 기준으로 매핑한 icon_code", + "mappedType": "integer" + }, "address": { "name": "address", "type": "varchar(255)", diff --git a/src/migrations/Migration20240727070747.ts b/src/migrations/Migration20240727070747.ts new file mode 100644 index 0000000..7789b8c --- /dev/null +++ b/src/migrations/Migration20240727070747.ts @@ -0,0 +1,13 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240727070747 extends Migration { + async up(): Promise { + this.addSql( + 'create table "category_icon_mapping" ("id" serial primary key, "kakao_category" varchar(255) not null, "category_group" varchar(255) not null, "icon_code" int not null, "created_at" timestamptz not null);', + ); + } + + async down(): Promise { + this.addSql('drop table if exists "category_icon_mapping" cascade;'); + } +} diff --git a/src/migrations/Migration20240727071134.ts b/src/migrations/Migration20240727071134.ts new file mode 100644 index 0000000..4685f40 --- /dev/null +++ b/src/migrations/Migration20240727071134.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240727071134 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "category_icon_mapping" alter column "created_at" type timestamptz using ("created_at"::timestamptz);', + ); + this.addSql( + 'alter table "category_icon_mapping" alter column "created_at" set default CURRENT_TIMESTAMP;', + ); + } + + async down(): Promise { + this.addSql( + 'alter table "category_icon_mapping" alter column "created_at" drop default;', + ); + this.addSql( + 'alter table "category_icon_mapping" alter column "created_at" type timestamptz using ("created_at"::timestamptz);', + ); + } +} diff --git a/src/migrations/Migration20240729140952.ts b/src/migrations/Migration20240729140952.ts new file mode 100644 index 0000000..879dd75 --- /dev/null +++ b/src/migrations/Migration20240729140952.ts @@ -0,0 +1,16 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240729140952 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "kakao_place" add column "category_icon_code" varchar(255) null;', + ); + this.addSql( + 'comment on column "kakao_place"."category_icon_code" is \'카카오맵 basicInfo.category.cate1name을 기준으로 매핑한 icon_code\';', + ); + } + + async down(): Promise { + this.addSql('alter table "kakao_place" drop column "category_icon_code";'); + } +} diff --git a/src/migrations/Migration20240729152526.ts b/src/migrations/Migration20240729152526.ts new file mode 100644 index 0000000..c73eba4 --- /dev/null +++ b/src/migrations/Migration20240729152526.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20240729152526 extends Migration { + async up(): Promise { + this.addSql( + 'alter table "kakao_place" alter column "category_icon_code" type int using ("category_icon_code"::int);', + ); + this.addSql( + 'comment on column "kakao_place"."category" is \'카카오맵 basicInfo.category.catename\';', + ); + } + + async down(): Promise { + this.addSql( + 'alter table "kakao_place" alter column "category_icon_code" type varchar(255) using ("category_icon_code"::varchar(255));', + ); + this.addSql( + 'comment on column "kakao_place"."category" is \'카카오맵 basicInfo.category.cate1name\';', + ); + } +} diff --git a/src/place/place.controller.ts b/src/place/place.controller.ts index f7906be..cde856f 100644 --- a/src/place/place.controller.ts +++ b/src/place/place.controller.ts @@ -48,6 +48,7 @@ export class PlaceController { @ApiOperation({ summary: '카카오 place id로 장소 등록' }) @ApiParam({ name: 'mapId', description: '지도(GroupMap) id' }) @ApiParam({ name: 'kakaoPlaceId', description: '카카오 place id' }) + @UseMapRoleGuard() @UseAuthGuard([UserRole.USER]) @Post(':mapId/kakao/:kakaoPlaceId') async registerPlaceByKakaoId( diff --git a/src/search/dtos/searched-place-response.dto.ts b/src/search/dtos/searched-place-response.dto.ts index c017400..ddf4be5 100644 --- a/src/search/dtos/searched-place-response.dto.ts +++ b/src/search/dtos/searched-place-response.dto.ts @@ -47,6 +47,9 @@ export class SearchedPlaceResponseDto { @IsOptional() likedUserIds?: number[]; + @ApiProperty({ description: '기타일 경우 100' }) + category_icon_code: number; + constructor(searchedPlace: KakaoPlaceItem | PlaceForMap) { if ('place' in searchedPlace) { const { place } = searchedPlace; @@ -64,14 +67,16 @@ export class SearchedPlaceResponseDto { this.createdBy = new CreatedUser(searchedPlace.createdBy); this.score = kakaoPlace.score; this.likedUserIds = searchedPlace.likedUserIds; + this.category_icon_code = kakaoPlace.categoryIconCode; } else { this.isRegisteredPlace = false; this.kakaoId = Number(searchedPlace.id); - this.category = searchedPlace.category_group_name; + this.category = searchedPlace.category_name; this.x = Number(searchedPlace.x); this.y = Number(searchedPlace.y); this.placeName = searchedPlace.place_name; this.address = searchedPlace.road_address_name; + this.category_icon_code = searchedPlace.category_icon_code; } } } diff --git a/src/search/kakao-map.types.ts b/src/search/kakao-map.types.ts index 46867a1..6770063 100644 --- a/src/search/kakao-map.types.ts +++ b/src/search/kakao-map.types.ts @@ -66,6 +66,7 @@ export type KakaoPlaceItem = { road_address_name: string; // "서울 강남구 논현로94길 13" x: string; // "127.037366122263" y: string; // "37.5026329250425" + category_icon_code: number; // category_name 기준으로 mapping }; export type KakaoPlaceDetailRaw = { diff --git a/src/search/search.module.ts b/src/search/search.module.ts index e478e24..b5f8aef 100644 --- a/src/search/search.module.ts +++ b/src/search/search.module.ts @@ -6,7 +6,7 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { UserModule } from 'src/user/user.module'; import { UtilModule } from 'src/util/util.module'; -import { KakaoPlace, PlaceForMap } from '../entities'; +import { CategoryIconMapping, KakaoPlace, PlaceForMap } from '../entities'; import { KakaoMapHelper } from './kakao-map.helper'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; @@ -15,7 +15,7 @@ import { SearchService } from './search.service'; imports: [ HttpModule, UtilModule, - MikroOrmModule.forFeature([KakaoPlace, PlaceForMap]), + MikroOrmModule.forFeature([KakaoPlace, PlaceForMap, CategoryIconMapping]), UserModule, ], controllers: [SearchController], diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 72ffb24..431b589 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -5,10 +5,12 @@ import { ConfigService } from '@nestjs/config'; import { rel } from '@mikro-orm/core'; import { InjectRepository } from '@mikro-orm/nestjs'; +import { DEFAULT_CATEGORY_ICON_CODE } from 'src/common/constants'; import { SearchedPlaceResponseDto } from 'src/search/dtos/searched-place-response.dto'; import { UtilService } from 'src/util/util.service'; import { + CategoryIconMappingRepository, GroupMap, KakaoPlace, KakaoPlaceRepository, @@ -35,6 +37,7 @@ export class SearchService { @InjectRepository(PlaceForMap) private readonly placeForMapRepository: PlaceForMapRepository, private readonly configService: ConfigService, + private readonly categoryMappingIconRepository: CategoryIconMappingRepository, ) {} async suggest(keyword: string): Promise { @@ -63,12 +66,15 @@ export class SearchService { this.searchPlace(query, rect, KakaoCategoryGroupCode['카페']), this.searchPlace(query, rect, KakaoCategoryGroupCode['음식점']), ]); - const searchedPlaces = this.utilService.uniqueBy( + const searchedPlaces: KakaoPlaceItem[] = this.utilService.uniqueBy( [...list1, ...list2], (item) => item.id, ); - return searchedPlaces.map((searchedPlace) => { + const placesWithIconCode = + await this.addCategoryIconCodeToArray(searchedPlaces); + + return placesWithIconCode.map((searchedPlace) => { return new SearchedPlaceResponseDto(searchedPlace); }); } @@ -87,7 +93,7 @@ export class SearchService { this.searchPlace(query, rect, KakaoCategoryGroupCode['카페']), this.searchPlace(query, rect, KakaoCategoryGroupCode['음식점']), ]); - const kakaoPlaceItems = this.utilService.uniqueBy( + const kakaoPlaceItems: KakaoPlaceItem[] = this.utilService.uniqueBy( [...list1, ...list2], (item) => item.id, ); @@ -97,7 +103,12 @@ export class SearchService { mapId, kakaoPlaceIds, ); - const mergedPlaces = kakaoPlaceItems.map( + + // TODO : 이미 등록된 장소를 중복으로 조회하지 않도록 개선 필요 + const placesWithIconCode: KakaoPlaceItem[] = + await this.addCategoryIconCodeToArray(kakaoPlaceItems); + + const mergedPlaces = placesWithIconCode.map( (kakaoPlace) => existingPlacesMap[kakaoPlace.id] ?? kakaoPlace, ); @@ -146,7 +157,12 @@ export class SearchService { kakaoPlace.id = basicInfo.cid; kakaoPlace.name = basicInfo.placenamefull; kakaoPlace.address = basicInfo.address.newaddr.newaddrfull; - kakaoPlace.category = basicInfo.category.cate1name; + kakaoPlace.category = this.utilService.parseSubCategory( + basicInfo.category.catename, + ); + kakaoPlace.categoryIconCode = await this.getCategoryIconCode( + basicInfo.category.catename, + ); kakaoPlace.blogReviewCnt = feedback.blogrvwcnt; kakaoPlace.commentCnt = feedback.comntcnt; kakaoPlace.mainPhotoUrl = basicInfo.mainphotourl; @@ -239,6 +255,47 @@ export class SearchService { }, }, ); + + response.data.documents.forEach((document) => { + document.category_name = this.utilService.parseSubCategory( + document.category_name, + ); + }); return response.data; } + + private async getCategoryIconCode(category: string): Promise { + const entity = await this.categoryMappingIconRepository.findOne({ + kakaoCategory: category, + }); + if (entity == null) return DEFAULT_CATEGORY_ICON_CODE; + return entity.iconCode; + } + + private async addCategoryIconCodeToArray( + list: KakaoPlaceItem[], + ): Promise { + const uniqueCategoryList: string[] = this.utilService.getUniqueFieldValues( + list, + 'category_name', + ); + + const categoryMappingList = await this.categoryMappingIconRepository.find({ + kakaoCategory: uniqueCategoryList, + }); + + const categoryMappingMap: Map = new Map( + categoryMappingList.map((mapping) => [ + mapping.kakaoCategory, + mapping.iconCode, + ]), + ); + + return list.map((searchedPlace: KakaoPlaceItem) => { + searchedPlace.category_icon_code = + categoryMappingMap.get(searchedPlace.category_name) || + DEFAULT_CATEGORY_ICON_CODE; + return searchedPlace; + }); + } } diff --git a/src/util/util.service.ts b/src/util/util.service.ts index 9568743..77b8f57 100644 --- a/src/util/util.service.ts +++ b/src/util/util.service.ts @@ -38,4 +38,13 @@ export class UtilService { .update(input + saltBytes) .digest('hex'); } + + getUniqueFieldValues(arr: T[], field: K): T[K][] { + return [...new Set(arr.map((item) => item[field]))]; + } + + parseSubCategory(catename: string): string { + const list = catename.split('> '); + return list[list.length - 1]; + } }