diff --git a/backend/src/modules/place/dto/place-search-query.dto.ts b/backend/src/modules/place/dto/place-search-query.dto.ts index f536532..6254c22 100644 --- a/backend/src/modules/place/dto/place-search-query.dto.ts +++ b/backend/src/modules/place/dto/place-search-query.dto.ts @@ -123,4 +123,14 @@ export class PlaceSearchQueryDto extends CursorPaginationQueryDto { @Transform(toBoolean) @IsBoolean({ message: 'indoor 는 true 또는 false 로 입력해 주세요.' }) indoor?: boolean; + + @ApiPropertyOptional({ + description: + '관광공사 장소와 운영자가 확인한 장소만 — 확인 안 된 상가정보 매장과 운영자가 추천하지 않기로 한 곳을 뺀다(장소 상세의 함께 가기 좋은 곳)', + example: true, + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean({ message: 'trusted 는 true 또는 false 로 입력해 주세요.' }) + trusted?: boolean; } diff --git a/backend/src/modules/place/place.service.ts b/backend/src/modules/place/place.service.ts index 7adbb43..c2f6712 100644 --- a/backend/src/modules/place/place.service.ts +++ b/backend/src/modules/place/place.service.ts @@ -88,6 +88,15 @@ export class PlaceService { settings: ['indoor', 'both'], }); } + if (query.trusted) { + // 확인 안 된 상가정보 매장이 추천처럼 보이지 않게 뺀다(2026-11-02 합의). 앱이 받아서 거르면 + // 도심처럼 상가가 빽빽한 곳에서는 가까운 몇 곳이 모두 상가라 칸이 비었다(2026-11-04 회귀 점검) + qb.andWhere('(v.source = :trustedSource OR v.verified_at IS NOT NULL)', { + trustedSource: 'tour', + }).andWhere('(v.verdict IS NULL OR v.verdict <> :notRecommend)', { + notRecommend: 'not_recommend', + }); + } // 괄호로 감싸야 TypeORM 이 엔티티 속성 선택으로 해석하지 않는다(그러면 결과 객체가 비어 버린다) let sortKey = '(v.name)'; diff --git a/backend/test/place.e2e-spec.ts b/backend/test/place.e2e-spec.ts index 882eff3..3debd6c 100644 --- a/backend/test/place.e2e-spec.ts +++ b/backend/test/place.e2e-spec.ts @@ -22,6 +22,10 @@ import { parseOpeningHours } from '@/shared/utils/opening-hours'; const C = { lat: 36.5, lng: 130.5 }; const at = (dLat: number) => ({ lat: C.lat + dLat, lng: C.lng }); const near = `lat=${C.lat}&lng=${C.lng}&radius=2000`; +/** 'trusted'(관광공사·확인 장소만) 검증용 묶음 — 위 묶음의 거리순 기대값에 섞이지 않게 5.5km 떨어뜨린다 */ +const C2 = { lat: 36.55, lng: 130.5 }; +const at2 = (dLat: number) => ({ lat: C2.lat + dLat, lng: C2.lng }); +const near2 = `lat=${C2.lat}&lng=${C2.lng}&radius=1000`; interface ListItem { id: string; @@ -186,6 +190,58 @@ describe('장소 조회 API (e2e)', () => { verifiedMethod: 'phone', verifiedAt: '2026-11-02', }); + + // trusted 묶음: 관광공사 1 · 확인 안 된 상가 1 · 확인한 상가 1 · 추천 안 함으로 판정한 관광공사·상가 각 1 + await save('R1', { + source: 'tour', + sourceId: 'e2e-R1', + category: 'attraction', + name: '이투이 바닷가 전망대', + ...at2(0.001), + }); + await save('R2', { + source: 'store', + sourceId: 'e2e-R2', + category: 'cafe', + name: '이투이 확인안한카페', + ...at2(0.0005), // 가장 가깝다 — trusted 가 아니면 첫 자리를 차지한다 + }); + await save('R3', { + source: 'store', + sourceId: 'e2e-R3', + category: 'cafe', + name: '이투이 확인한카페', + ...at2(0.002), + }); + await save('R4', { + source: 'tour', + sourceId: 'e2e-R4', + category: 'cafe', + name: '이투이 추천안함카페', + ...at2(0.0025), + }); + await save('R5', { + source: 'store', + sourceId: 'e2e-R5', + category: 'bar', + name: '이투이 추천안함바', + ...at2(0.003), + }); + const curations = ds.getRepository(PlaceCuration); + await curations.save({ + placeId: ids.R3, + verdict: 'pending', + verifiedMethod: 'official_channel', + verifiedAt: '2026-11-04', + }); + for (const key of ['R4', 'R5']) { + await curations.save({ + placeId: ids[key], + verdict: 'not_recommend', + verifiedMethod: 'visit', + verifiedAt: '2026-11-04', + }); + } }); afterAll(async () => { @@ -248,6 +304,21 @@ describe('장소 조회 API (e2e)', () => { expect(names(res)).toEqual(['이투이카페', '이투이 골목카페']); }); + it('trusted 는 관광공사·운영자 확인 장소만 주고, 추천 안 함으로 판정한 곳은 뺀다', async () => { + const all = await get(`/api/places?${near2}`).expect(200); + expect(list(all).data.pageInfo.total).toBe(5); + const res = await get(`/api/places?${near2}&trusted=true`).expect(200); + expect(names(res)).toEqual(['이투이 바닷가 전망대', '이투이 확인한카페']); + expect(list(res).data.pageInfo.total).toBe(2); + // 가장 가까운 곳이 확인 안 된 상가여도 limit 안에 관광공사·확인 장소가 채워진다 + const plain = await get(`/api/places?${near2}&limit=1`).expect(200); + expect(names(plain)).toEqual(['이투이 확인안한카페']); + const one = await get(`/api/places?${near2}&trusted=true&limit=1`).expect( + 200, + ); + expect(names(one)).toEqual(['이투이 바닷가 전망대']); + }); + it('좌표 없이 이름 검색하면 이름순으로 반경 제한 없이 준다', async () => { const res = await get( `/api/places?q=${encodeURIComponent('이투이')}`, @@ -266,6 +337,7 @@ describe('장소 조회 API (e2e)', () => { ['없는 분류', 'category=pub'], ['형식이 틀린 커서', `${near}&cursor=abc`], ['true/false 가 아닌 필터', 'pet=yes'], + ['true/false 가 아닌 trusted', 'trusted=1'], ])('%s 는 VAL_001(400)', async (_, query) => { const res = await get(`/api/places?${query}`).expect(400); expect(detail(res).error?.code).toBe('VAL_001'); diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 6ac134c..312e330 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -355,11 +355,14 @@ node dist/commands/place-sync.js tour-kor | 엔드포인트 | 내용 | | --- | --- | -| `GET /api/places` | `q`(이름) · `lat`+`lng`+`radius`(기본 1,500m, 100~20,000) · `category`(쉼표 구분) · `pet` · `indoor` · `cursor` · `limit`. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 `{items, pageInfo}` 에 `total` 포함 | +| `GET /api/places` | `q`(이름) · `lat`+`lng`+`radius`(기본 1,500m, 100~20,000) · `category`(쉼표 구분) · `pet` · `indoor` · `trusted` · `cursor` · `limit`. 좌표가 있으면 거리순, 없으면 이름순. 키셋 커서(정렬값+id). 응답 `{items, pageInfo}` 에 `total` 포함 | | `GET /api/places/{id}` | 상세 — 영업시간(원문·해석 상태·출처), 반려동물(크기·조건), 이용 안내(요금·주차 등), 추천 이유(추천+확인한 곳만), 출처 표기. 병합된 id 면 대상 장소, 숨김·폐업 추정이면 RES_001 | - 목록 항목의 `openNow` 는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인". - 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제). +- `trusted=true` 는 관광공사 장소와 운영자가 확인한 장소만 준다(확인 안 된 상가정보 매장·'추천 안 함' 판정 제외). 장소 상세 '함께 가기 좋은 곳'이 쓴다. + 앱이 가까운 30곳을 받아 거르던 때는 도심처럼 상가가 빽빽한 곳에서 받은 곳이 모두 상가라 칸이 비었다 + (2026-11-04 회귀 점검: 관광공사 장소 상세 860곳 중 30곳은 칸이 사라지고 186곳은 덜 보였다). - 행사 목록에서 끝(`to`)을 생략하고 없는 시작일(`from=2030-13-01`)을 보내면 500 이 나던 것을 VAL_001(400)로 고쳤다(2026-11-04). - 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다. - 계약 테스트: `backend/test/place.e2e-spec.ts` — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개).