[feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록

This commit is contained in:
김성경 2026-09-11 11:58:09 +09:00
parent 1dd2dc773e
commit 9d4feebb86
5 changed files with 102 additions and 8 deletions

View File

@ -83,6 +83,9 @@ class ResLocalGuide(Res_WebPacketProtocol):
restaurants: list[dict[str, Any]] = [] restaurants: list[dict[str, Any]] = []
festivals: list[dict[str, Any]] = [] festivals: list[dict[str, Any]] = []
courses: list[dict[str, Any]] = [] courses: list[dict[str, Any]] = []
# ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와
# **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다.
itineraries: list[dict[str, Any]] = []
synced_at: str | None = None synced_at: str | None = None

View File

@ -363,7 +363,8 @@ class LocalContentService:
★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 — ★ 스냅샷 필터(숨김·노출창)와 payload 변환을 **그대로 재사용**한다 —
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다. 캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다"고 읽는다.
그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다. 그래서 여기서 DB 를 따로 읽지 않고 발행 파이프라인의 두 함수를 잇기만 한다.
★ 일정(itineraries)은 payload 가 만들어도 여기선 내려보내지 않는다 — 캔버스에 그릴 자리가 아직 없다. ★ 일정(itineraries)도 함께 내려보낸다 — 캔버스 `ItineraryTickets` 가 섹션 데이터가 비었을 때
이 값으로 떨어진다(발행본 ItinerarySection 과 같은 폴백). 예전에는 그릴 자리가 없어 뺐다.
""" """
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다. # 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local from services.site_payload import _local
@ -399,6 +400,7 @@ class LocalContentService:
res.restaurants = local.get("restaurants") or [] res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or [] res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or [] res.courses = local.get("courses") or []
res.itineraries = local.get("itineraries") or []
res.synced_at = synced_at res.synced_at = synced_at
return res return res

View File

@ -0,0 +1,76 @@
"""캔버스 가이드가 일정을 함께 내려보내는가.
★ 예전에는 일부러 뺐다 — "캔버스에 그릴 자리가 아직 없다"(get_guide docstring).
자리를 만들었으므로(ItineraryTickets 의 폴백) 이제 실어 보낸다.
빼 두면 캔버스와 발행본이 다른 목록을 보이고, 그건 이 함수 자신이 경계한 상황이다.
"""
import uuid
from sqlalchemy import text
from common.enums import PlaceCategory, SourceType
async def _seed_place(db_engine, owner_id, name="스테이,머뭄") -> str:
pid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, road_address, "
"region_code, latitude, longitude, status) "
"VALUES (:p, :o, :n, :c, :r, :rc, :lat, :lng, 1)"),
{"p": pid, "o": uuid.UUID(owner_id), "n": name, "c": PlaceCategory.LODGING.value,
"r": "전북특별자치도 군산시 절골길 18", "rc": "52군산시",
"lat": 35.98642, "lng": 126.70612},
)
return str(pid)
async def _seed_itinerary(db_engine, place_id, duration="1박 2일", course="원도심 코스"):
body = [{
"name": course, "duration": duration, "audience": "처음 온 손님", "why": "이유.",
"days": [{"label": "첫째 날", "startTime": "14:00",
"stops": [{"name": "군산근대역사박물관", "minutes": 90, "moveMinutes": 15,
"searchQuery": "군산근대역사박물관",
"latitude": 35.9985, "longitude": 126.7107}]}],
"source": {"name": "군산문화관광", "url": "https://www.gunsan.go.kr/tour/"},
}]
import json
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO place_itineraries (place_itinerary_id, place_id, duration, body, "
"generated_by, model, generated_at) "
"VALUES (:i, :p, :d, CAST(:b AS jsonb), :g, :m, now())"),
{"i": uuid.uuid4(), "p": uuid.UUID(place_id), "d": duration,
"b": json.dumps(body, ensure_ascii=False),
"g": SourceType.LLM.value, "m": "perplexity:sonar"},
)
async def test_guide_returns_stored_itineraries(client, db_engine, owner_id, monkeypatch):
"""검증: 저장된 일정이 있는 업장의 캔버스 가이드.
기대결과: itineraries 가 실려 온다 — 캔버스가 발행본과 같은 목록을 본다."""
# 생성·수집은 이 테스트의 관심이 아니다. 유료 호출과 잡 등록을 막는다.
monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False)
pid = await _seed_place(db_engine, owner_id)
await _seed_itinerary(db_engine, pid)
body = (await client.get("/v1/local/guide", params={"place_id": pid})).json()
assert body["result"]["success"] is True
assert [i["name"] for i in body["itineraries"]] == ["원도심 코스"]
assert body["itineraries"][0]["duration"] == "1박 2일"
assert body["itineraries"][0]["days"][0]["stops"][0]["name"] == "군산근대역사박물관"
async def test_guide_without_itineraries_returns_empty_list(client, db_engine, owner_id, monkeypatch):
"""검증: 아직 생성되지 않은 업장.
기대결과: 빈 배열 — 캔버스는 PasteHint 를 띄운다(에러가 아니다)."""
monkeypatch.setattr("services.llm.perplexity.is_configured", lambda: False)
pid = await _seed_place(db_engine, owner_id)
body = (await client.get("/v1/local/guide", params={"place_id": pid})).json()
assert body["result"]["success"] is True
assert body.get("itineraries", []) == []

View File

@ -5,6 +5,8 @@
* ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다. * ★ 순위를 매기지 않는다 — 어느 일정이 1위인지는 우리가 정할 일이 아니다.
* ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간. * ★ 시각은 사장님이 적는 게 아니라 **계산한다**. 출발 시각 + 이동 + 머무는 시간.
* 출발을 당기면 하루가 통째로 밀린다. * 출발을 당기면 하루가 통째로 밀린다.
* ★ 사장님이 붙여넣은 것이 없으면 **서버가 만든 일정**을 쓴다(useLocalGuide). 발행본
* ItinerarySection 과 같은 폴백이다 — 둘이 갈리면 "미리보기와 다르다"가 된다.
*/ */
import { import {
currentSeasons, currentSeasons,
@ -15,6 +17,7 @@ import {
type ItineraryItem, type ItineraryItem,
type PlannedStop, type PlannedStop,
} from '@o2o/shared'; } from '@o2o/shared';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {SectionBody, SectionFrame, Rail} from '../../primitives'; import {SectionBody, SectionFrame, Rail} from '../../primitives';
import type {SectionRenderProps} from '../../types'; import type {SectionRenderProps} from '../../types';
import { import {
@ -32,11 +35,14 @@ import '../items/items.css';
export function ItineraryTickets(props: SectionRenderProps) { export function ItineraryTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props; const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ItineraryItem>(section.type, section.data); const parsed = parseSectionData<ItineraryItem>(section.type, section.data);
const guide = useLocalGuide();
// 사장님이 적은 것이 언제나 이긴다 — 서버가 만든 건 비었을 때의 기본값이다.
const items = parsed.items.length > 0 ? parsed.items : guide.itineraries;
const live = currentSeasons(); const live = currentSeasons();
const durations = itineraryDurations(parsed.items); const durations = itineraryDurations(items);
const groups: (string | undefined)[] = const groups: (string | undefined)[] =
durations.length > 0 durations.length > 0
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])] ? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
: [undefined]; : [undefined];
return ( return (
@ -55,17 +61,17 @@ export function ItineraryTickets(props: SectionRenderProps) {
{parsed.error ? ( {parsed.error ? (
<ParseError message={parsed.error} /> <ParseError message={parsed.error} />
) : parsed.items.length === 0 ? ( ) : items.length === 0 ? (
<PasteHint label="추천 일정" /> <PasteHint label="추천 일정" />
) : ( ) : (
<div className="space-y-7"> <div className="space-y-7">
{groups.map((duration) => { {groups.map((duration) => {
const items = parsed.items.filter((item) => const dayItems = items.filter((item) =>
duration === undefined duration === undefined
? !item.duration?.trim() ? !item.duration?.trim()
: item.duration?.trim() === duration, : item.duration?.trim() === duration,
); );
if (items.length === 0) return null; if (dayItems.length === 0) return null;
return ( return (
<div key={duration ?? 'etc'} className="space-y-4"> <div key={duration ?? 'etc'} className="space-y-4">
{duration && ( {duration && (
@ -73,7 +79,7 @@ export function ItineraryTickets(props: SectionRenderProps) {
{duration} {duration}
</h3> </h3>
)} )}
{items.map((item, index) => ( {dayItems.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5"> <div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1"> <div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}> <h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>

View File

@ -1,4 +1,5 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import type {ItineraryItem} from '@o2o/shared';
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types'; import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
@ -6,11 +7,16 @@ export interface LocalGuideData {
foods: GuideCard[]; foods: GuideCard[];
spots: GuideCard[]; spots: GuideCard[];
festivals: FestivalCard[]; festivals: FestivalCard[];
/**
* 서버가 만든 추천 일정(1박2일·2박3일 각 5개). **모양을 바꾸지 않는다** —
* 맛집·명소와 달리 이미 렌더러 계약(ItineraryItem)이라 toCard 를 거치지 않는다.
*/
itineraries: ItineraryItem[];
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */ /** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
syncedAt?: string; syncedAt?: string;
} }
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []}; const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: [], itineraries: []};
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다. // 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
const MAX_PER_TYPE = 20; const MAX_PER_TYPE = 20;
@ -91,6 +97,7 @@ export function useLocalGuide(): LocalGuideData {
foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), foods: ((data.restaurants ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard), spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival), festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
itineraries: Array.isArray(data.itineraries) ? data.itineraries : [],
syncedAt: data.synced_at ?? undefined, syncedAt: data.synced_at ?? undefined,
}; };
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면 // ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면