generated from ttipo/project-base
fix(backend): 영업시간 해석 오류와 행사 날짜 500 수정, 함께 가기용 trusted 조건 추가 #4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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개).
|
||||
|
||||
Reference in New Issue
Block a user