## 업종 교체 (tour → clinic) PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다. - category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시 운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다. unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항). - 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 — 이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다. - jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다. - 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로. - mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다. - site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 — test_site_theme 이 이 둘을 대조한다. ## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert) 두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다. `/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다. 위저드를 열어 두고 에디터 진입에서 한 번 받는969fb67설계로 되돌린다.d6a6c8e가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다. ## 그 밖 - test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락). - .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면 nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다. 검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed — 그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
240 lines
9.1 KiB
TypeScript
240 lines
9.1 KiB
TypeScript
/**
|
|
* 백엔드 응답 → 캔버스 데이터.
|
|
*
|
|
* ★ 순수 함수만 둔다. 스토어도 네트워크도 모른다 — 입력(place·fact·media·스키마)이 같으면
|
|
* 출력이 항상 같다. 그래서 "화면이 왜 저 값을 보여주나"를 이 파일만 읽고 답할 수 있다.
|
|
*
|
|
* ★ 필드명은 지어내지 않는다. 기준은 backend/router/v1/place/protocol.py 의 PlaceData 와
|
|
* backend/router/v1/fact/protocol.py 의 FactData · FieldSpecData 다.
|
|
*/
|
|
import type {IndustryType, InfoField, PhotoItem} from '@o2o/shared';
|
|
import {isPublishableFact, PlaceCategory, SourceType} from '@o2o/shared';
|
|
import type {FactData, FieldSpecData, MediaData, PlaceData} from '@/api';
|
|
import {FALLBACK_INDUSTRY} from '@/data/industryData';
|
|
import type {FactRef, LivePlaceInput} from '@/stores/builderTypes';
|
|
|
|
/** places.category → 빌더 업종. 어떤 업종 시드(섹션·템플릿·문구)를 깔지가 여기서 갈린다. */
|
|
export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = {
|
|
[PlaceCategory.LODGING]: 'stay',
|
|
[PlaceCategory.CAFE]: 'cafe',
|
|
[PlaceCategory.RESTAURANT]: 'restaurant',
|
|
[PlaceCategory.CLINIC]: 'clinic',
|
|
};
|
|
|
|
/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */
|
|
const SOURCE_LABEL: Record<number, string> = {
|
|
[SourceType.OWNER]: '사장님 입력',
|
|
[SourceType.API]: '외부 API',
|
|
[SourceType.CRAWL]: '채널 수집',
|
|
[SourceType.LLM]: 'AI 초안',
|
|
};
|
|
|
|
/** 출처 한 줄. URL 은 통째로 쓰면 패널을 밀어내므로 호스트만 남긴다. */
|
|
function factSource(fact: FactData): string {
|
|
const label = SOURCE_LABEL[fact.source_type] ?? '출처 미상';
|
|
if (!fact.source_url) return label;
|
|
try {
|
|
return `${label} · ${new URL(fact.source_url).hostname}`;
|
|
} catch {
|
|
return label;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* fact 값을 사람이 읽는 문장으로.
|
|
*
|
|
* bool 은 'true' 가 그대로 화면에 나가면 안 되고, number 는 천 단위 구분이 없으면
|
|
* 사장님도 AI 도 자릿수를 세야 한다(shared/lib/facts.ts 의 factText 와 같은 규칙).
|
|
*/
|
|
function formatFactValue(fact: FactData, spec?: FieldSpecData): string {
|
|
const raw = (fact.value ?? '').trim();
|
|
if (!raw) return '';
|
|
const unit = fact.unit ?? spec?.unit ?? '';
|
|
if (spec?.type === 'bool') return raw === 'true' || raw === '1' || raw === 'Y' ? '예' : '아니오';
|
|
if (spec?.type === 'number') {
|
|
const num = Number(raw.replace(/,/g, ''));
|
|
return `${Number.isFinite(num) ? num.toLocaleString('ko-KR') : raw}${unit}`;
|
|
}
|
|
return `${raw}${unit}`;
|
|
}
|
|
|
|
/** place 가 소유하는 줄. fact 가 아니므로 전이시킬 대상이 없다(id 가 겹치면 안 된다). */
|
|
const IDENTITY_FIELD_IDS = new Set(['name', 'address', 'phone']);
|
|
|
|
/**
|
|
* place 응답의 신원 정보(상호·주소·전화).
|
|
*
|
|
* fact 가 아직 하나도 없어도 이건 채워진다 — 수집 전 사업장을 열어도 캔버스가 빈 껍데기가
|
|
* 되지 않게. id 는 데모 시드와 같은 이름을 쓴다(캔버스 푸터가 'address' 를 이름으로 찾는다).
|
|
*/
|
|
function identityFields(place: PlaceData): InfoField[] {
|
|
// ★ verified_at 이 NULL 이면 동일 업소 검증 전이다 — 주소·전화가 동명 업소의 것일 수 있다.
|
|
// 그래서 '확인 필요'로 올린다: 캔버스가 가리고, 발행 게이트가 잡는다(절대규칙 1).
|
|
const verified = Boolean(place.verified_at);
|
|
const address = place.road_address ?? place.address ?? '';
|
|
|
|
const fields: InfoField[] = [
|
|
{
|
|
id: 'name',
|
|
label: '상호',
|
|
value: place.name,
|
|
requiresVerification: false,
|
|
isVerified: true,
|
|
source: '사장님 등록',
|
|
},
|
|
];
|
|
|
|
if (address) {
|
|
fields.push({
|
|
id: 'address',
|
|
label: '주소',
|
|
value: address,
|
|
requiresVerification: !verified,
|
|
isVerified: verified,
|
|
source: '동일 업소 검증',
|
|
critical: true,
|
|
});
|
|
}
|
|
if (place.phone) {
|
|
fields.push({
|
|
id: 'phone',
|
|
label: '전화',
|
|
value: place.phone,
|
|
requiresVerification: !verified,
|
|
isVerified: verified,
|
|
source: '동일 업소 검증',
|
|
critical: true,
|
|
});
|
|
}
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* key 당 대표 fact 1건(scope='unit' 인 값은 뺀다).
|
|
*
|
|
* ★ 정보 표와 저장 배선이 반드시 같은 fact 를 가리켜야 한다 — 서로 다른 걸 고르면
|
|
* 화면에서 보고 있는 줄과 실제로 전이시키는 fact 가 어긋나, 엉뚱한 값이 '확인됨'이 된다.
|
|
*/
|
|
function placeFactsByKey(facts: FactData[]): Map<string, FactData> {
|
|
const byKey = new Map<string, FactData>();
|
|
for (const fact of facts) {
|
|
if (fact.unit_id) continue;
|
|
if (!byKey.has(fact.key)) byKey.set(fact.key, fact);
|
|
}
|
|
return byKey;
|
|
}
|
|
|
|
/** 정보 표의 줄 id → 어떤 fact 를 전이시킬지. 화면과 같은 규칙(placeFactsByKey)으로 고른다. */
|
|
function toFactRefs(facts: FactData[], specs: FieldSpecData[]): Record<string, FactRef> {
|
|
const specByKey = new Map(specs.map((spec) => [spec.key, spec]));
|
|
const refs: Record<string, FactRef> = {};
|
|
for (const [key, fact] of placeFactsByKey(facts)) {
|
|
// 신원 줄과 id 가 겹치면 사장님이 보는 줄과 다른 fact 를 고치게 된다. 그럴 바엔 로컬로 둔다.
|
|
if (IDENTITY_FIELD_IDS.has(key)) continue;
|
|
const spec = specByKey.get(key);
|
|
refs[key] = {
|
|
factId: fact.fact_id,
|
|
status: fact.status,
|
|
type: spec?.type,
|
|
// 단위는 fact 가 우선이다(수집 시점의 값). 없으면 업종 스키마의 단위.
|
|
unit: fact.unit ?? spec?.unit ?? undefined,
|
|
};
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
/** fact 1건 → 정보 표 한 줄. */
|
|
function factField(fact: FactData, spec?: FieldSpecData): InfoField {
|
|
const publishable = isPublishableFact(fact.status);
|
|
return {
|
|
id: fact.key,
|
|
label: spec?.label ?? fact.key,
|
|
value: formatFactValue(fact, spec),
|
|
// ★ 절대규칙 1 — VERIFIED · CORRECTED 가 아니면 캔버스가 가린다.
|
|
requiresVerification: !publishable,
|
|
isVerified: publishable,
|
|
source: factSource(fact),
|
|
critical: spec?.critical,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* place + fact + 업종 스키마 → 캔버스의 infoFields.
|
|
*
|
|
* 줄 순서는 업종 스키마(FieldSpecData)가 정한다. 수집된 순서대로 늘어놓으면 재수집이
|
|
* 돌 때마다 표의 줄 순서가 바뀐다 — 사장님 눈에는 값이 바뀐 것처럼 보인다.
|
|
*
|
|
* ★ scope='unit' 인 fact(객실·메뉴별 값)는 넣지 않는다. 같은 key 가 단위 수만큼 들어와
|
|
* '기본 정보' 표에 같은 라벨이 여러 줄 겹친다 — 단위별 값은 객실/메뉴 섹션의 몫이다.
|
|
*/
|
|
function toInfoFields(place: PlaceData, facts: FactData[], specs: FieldSpecData[]): InfoField[] {
|
|
const placeFacts = placeFactsByKey(facts);
|
|
|
|
const ordered: InfoField[] = [];
|
|
const taken = new Set<string>();
|
|
for (const spec of specs) {
|
|
if (spec.scope !== 'place') continue;
|
|
const fact = placeFacts.get(spec.key);
|
|
if (!fact) continue;
|
|
taken.add(spec.key);
|
|
ordered.push(factField(fact, spec));
|
|
}
|
|
// 스키마에 없는 key 도 버리지 않는다(스키마 배포보다 수집이 앞선 경우).
|
|
for (const [key, fact] of placeFacts) {
|
|
if (!taken.has(key)) ordered.push(factField(fact));
|
|
}
|
|
|
|
// 값이 빈 fact 는 표에 넣지 않는다 — 라벨만 있는 빈 줄은 정보가 아니라 잡음이다.
|
|
return [...identityFields(place), ...ordered.filter((f) => f.value !== '')];
|
|
}
|
|
|
|
/**
|
|
* 서버 media → 캔버스 사진.
|
|
*
|
|
* ★ **발행 가능한 것만** 넘긴다(승인 + alt 있음). 미승인 사진을 캔버스에 그리면
|
|
* 사장님은 그게 사이트에 나갈 것으로 읽는데, 발행 스냅샷은 그걸 걸러낸다 —
|
|
* 화면과 발행본이 갈리는 자리다. `publishable` 은 서버가 판정해 내려준다.
|
|
*/
|
|
function toPhotos(media: MediaData[]): PhotoItem[] {
|
|
return media
|
|
.filter((m) => m.publishable !== false)
|
|
.map((m, index) => ({
|
|
id: m.media_id,
|
|
url: m.url ?? m.origin_url ?? '',
|
|
// alt 는 Vision 이 붙인 설명이다. 없으면 라벨(분류)로 떨어뜨린다.
|
|
title: m.alt_text || m.label || '수집된 사진',
|
|
category: m.label ?? '기타',
|
|
isPrimary: index === 0,
|
|
isVisible: true,
|
|
}))
|
|
.filter((p) => p.url);
|
|
}
|
|
|
|
export function toLivePlaceInput(
|
|
placeId: string,
|
|
place: PlaceData,
|
|
facts: FactData[],
|
|
specs: FieldSpecData[],
|
|
media: MediaData[],
|
|
): LivePlaceInput {
|
|
return {
|
|
placeId,
|
|
industry: CATEGORY_TO_INDUSTRY[place.category] ?? FALLBACK_INDUSTRY,
|
|
storeName: place.name,
|
|
location: place.road_address ?? place.address ?? '',
|
|
weatherLocation:
|
|
place.region_code && place.latitude != null && place.longitude != null
|
|
? {
|
|
regionCode: place.region_code,
|
|
latitude: Number(place.latitude),
|
|
longitude: Number(place.longitude),
|
|
}
|
|
: undefined,
|
|
infoFields: toInfoFields(place, facts, specs),
|
|
factRefs: toFactRefs(facts, specs),
|
|
// fact 가 하나도 없으면 아직 수집 전이다(신원 2줄은 place 가 준 것이지 수집물이 아니다).
|
|
hasCollected: facts.length > 0,
|
|
photos: toPhotos(media),
|
|
};
|
|
}
|