o2o-site-AEO/solution/frontend/src/features/onboarding/usePlaceSearch.ts
Mina Choi 9b4fe4030b [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합
킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.

- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
  플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
  프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
  옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다

설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
  하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다

배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
  solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
  이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
  하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
  전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설

정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
  평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
  solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시

검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
2026-09-01 10:04:36 +09:00

278 lines
11 KiB
TypeScript

import {useCallback, useRef, useState} from 'react';
import type {IndustryType} from '@o2o/shared';
import type {ExternalPlaceSource as ExternalPlaceSourceType, PlaceCandidate, PlaceCategory} from '@/api';
import {
createPlace,
ExternalPlaceSource,
getAccessToken,
PlaceCategory as PlaceCategoryEnum,
updatePlace,
verifyCandidates,
verifyPlace,
verifyPlaceByUrl,
} from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder';
import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE,
restaurant: PlaceCategoryEnum.RESTAURANT,
tour: PlaceCategoryEnum.TOUR_ACTIVITY,
};
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
const SOURCE_LABEL: Record<number, string> = {
[ExternalPlaceSource.KAKAO]: '카카오맵',
[ExternalPlaceSource.NAVER]: '네이버 지도',
};
export type SearchPhase =
/** 아직 검색 전. */
| 'idle'
/** 사업장 생성 + 후보 조회 중. */
| 'searching'
/** 후보를 받았다(0건일 수도 있다 — outcome 이 no_candidate). */
| 'done'
/**
* 장소 DB 를 부를 수 없다 — 로그인이 없거나 백엔드가 응답하지 않는다.
* ★ 이때 가짜 후보를 지어내지 않는다. 검색 결과인 척하는 예시 카드는
* 사장님이 남의 가게를 자기 가게로 확정하게 만드는 가장 빠른 길이다.
*/
| 'unavailable';
export interface PlaceSearchState {
phase: SearchPhase;
/** 외부 장소 DB 후보. 서버가 자동 확정하지 않으므로 항상 사람이 고른다. */
candidates: PlaceCandidate[];
/** matched | ambiguous | no_candidate — 서버 판정. 문구를 고르는 데만 쓴다. */
outcome: string;
/** true 면 판정이 명확해 '이거 맞나요?' 한 번만 물어도 된다. */
autoSelectable: boolean;
/** 후보를 준 장소 DB 이름. 없으면 빈 문자열. */
sourceLabel: string;
source: ExternalPlaceSourceType | null;
/** 검색을 못 한 이유(로그인 필요 등). phase === 'unavailable' 일 때만 채워진다. */
unavailableReason: string;
}
const INITIAL: PlaceSearchState = {
phase: 'idle',
candidates: [],
outcome: '',
autoSelectable: false,
sourceLabel: '',
source: null,
unavailableReason: '',
};
/**
* 상호 검색 → 동일 업소 후보 조회 → 사람이 고른 후보로 확정.
*
* 백엔드 순서가 그대로 화면 순서다:
* 1) POST /v1/place 사업장(껍데기)을 만든다 — 후보 조회가 place_id 를 요구한다
* 2) GET /v1/place/{id}/verify/candidates?query= 외부 장소 DB 후보
* 3) POST /v1/place/{id}/verify 사람이 고른 후보로 동일 업소 확정
*
* ★ 3 을 통과해야 수집이 열린다(place.py: "동일 업소 검증과 채널 URL 확정이 끝나야 시작할 수 있다").
* 그래서 직접 입력 경로는 수집 없이 사장님 입력만으로 사이트를 만든다 — 검증을 우회하지 않는다.
*/
export function usePlaceSearch(industry: IndustryType, existingPlaceId: string | null = null) {
const [state, setState] = useState<PlaceSearchState>(INITIAL);
const [isConfirming, setIsConfirming] = useState(false);
/**
* 이 위저드가 만든 사업장. 상호를 고쳐 다시 검색해도 사업장을 새로 만들지 않는다.
* ★ 새로고침을 넘어온 경우 스토어에 남아 있는 사업장을 그대로 이어받는다 — 안 그러면
* 되살아난 화면이 같은 가게를 한 번 더 만든다.
*/
const placeIdRef = useRef<string | null>(existingPlaceId);
const inflight = useRef<AbortController | null>(null);
/** 마지막으로 검색한 상호. URL 확정 때 사업장 껍데기 이름으로 쓴다. */
const nameRef = useRef<string>('');
const reset = useCallback(() => {
inflight.current?.abort();
inflight.current = null;
setState(INITIAL);
}, []);
/** 사업장 껍데기 확보. 이미 만들었으면 상호만 맞춰 둔다(같은 위저드에서 두 번 만들지 않는다). */
const ensurePlace = useCallback(
async (name: string, signal: AbortSignal): Promise<string | null> => {
if (placeIdRef.current) {
// PATCH 는 취소 신호를 받지 않는다(생성물 시그니처) — 뒤에서 signal 로 결과를 버린다.
await updatePlace(placeIdRef.current, {name});
if (signal.aborted) return null;
return placeIdRef.current;
}
const created = await createPlace(
{name, category: INDUSTRY_TO_CATEGORY[industry]},
undefined,
signal,
);
placeIdRef.current = created.place?.place_id ?? null;
return placeIdRef.current;
},
[industry],
);
const search = useCallback(
async (name: string, location: string) => {
nameRef.current = name.trim();
const query = [name.trim(), location.trim()].filter(Boolean).join(' ');
if (!name.trim()) return;
inflight.current?.abort();
const controller = new AbortController();
inflight.current = controller;
if (!getAccessToken()) {
setState({
...INITIAL,
phase: 'unavailable',
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
});
return;
}
setState({...INITIAL, phase: 'searching'});
try {
const placeId = await ensurePlace(name.trim(), controller.signal);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
if (controller.signal.aborted) return;
const source = res.source ?? null;
setState({
phase: 'done',
candidates: res.candidates ?? [],
outcome: res.outcome ?? '',
autoSelectable: res.auto_selectable ?? false,
source,
sourceLabel: source ? (SOURCE_LABEL[source] ?? '외부 장소 DB') : '외부 장소 DB',
unavailableReason: '',
});
} catch (error) {
if (controller.signal.aborted) return;
notifyApiError(error, '상호를 검색하지 못했습니다.');
setState({
...INITIAL,
phase: 'unavailable',
unavailableReason:
'장소 DB 를 부르지 못했습니다. 백엔드가 떠 있는지 확인하시거나, 직접 입력으로 진행해 주세요.',
});
}
},
[ensurePlace],
);
/**
* 사람이 고른 후보를 이 사업장의 신원으로 확정한다(POST /verify).
* 성공하면 화면에 얹을 신원을 돌려준다 — 실패하면 null.
*/
const confirm = useCallback(
async (candidate: PlaceCandidate): Promise<ConfirmedIdentity | null> => {
const placeId = placeIdRef.current;
const address = candidate.road_address ?? candidate.address ?? '';
if (!placeId) return null;
setIsConfirming(true);
try {
await verifyPlace(placeId, {
source: state.source ?? ExternalPlaceSource.KAKAO,
// 네이버는 고유 id 를 주지 않는다 — 그 경우 상호명 + 도로명주소가 중복 판정 키다.
external_place_id: candidate.external_place_id ?? '',
road_address: candidate.road_address ?? null,
address: candidate.address ?? null,
phone: candidate.phone ?? null,
latitude: candidate.latitude ?? null,
longitude: candidate.longitude ?? null,
place_url: candidate.place_url ?? null,
});
// ★ 상호도 후보 쪽으로 맞춘다. 사장님이 그 이름이 적힌 카드를 보고 "이 가게예요"를
// 눌렀는데 DB 에는 검색어로 친 이름이 남으면, 화면과 서버가 다른 상호를 들고 있게 된다.
const officialName = candidate.name?.trim();
if (officialName) await updatePlace(placeId, {name: officialName});
return {
placeId,
name: candidate.name ?? '',
address,
phone: candidate.phone ?? undefined,
origin: 'external',
sourceLabel: state.sourceLabel || '외부 장소 DB',
externalPlaceId: candidate.external_place_id ?? undefined,
placeUrl: candidate.place_url ?? undefined,
};
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[state.source, state.sourceLabel],
);
/**
* 네이버 플레이스 URL 로 확정한다.
*
* ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고
* (실측: '롯데호텔 서울'), 검색모델은 네이버 플레이스를 아예 못 찾는다.
* 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원·채널이 동시에 확정된다.
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
*/
const confirmByUrl = useCallback(
async (url: string): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true);
try {
const placeId = await ensurePlace(nameRef.current || '내 가게', new AbortController().signal);
if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place;
if (!place) {
notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.');
return null;
}
return {
placeId,
name: place.name ?? '',
address: place.road_address ?? place.address ?? '',
phone: place.phone ?? undefined,
origin: 'external',
sourceLabel: '네이버 플레이스',
externalPlaceId: place.external_place_id ?? undefined,
};
} catch (error) {
notifyApiError(error, '이 주소로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
},
[ensurePlace],
);
/**
* 목록에 없거나 장소 DB 를 못 부를 때 — 사장님이 직접 댄 값으로 신원을 세운다.
*
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다.
*/
const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
return {
placeId: placeIdRef.current,
name: name.trim(),
address: address.trim(),
origin: 'owner',
sourceLabel: '직접 입력',
};
}, []);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
}