[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]] = []
festivals: 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

View File

@ -363,7 +363,8 @@ class LocalContentService:
스냅샷 필터(숨김·노출창) payload 변환을 **그대로 재사용**한다
캔버스가 발행본과 다른 목록을 보이면 사장님이 "미리보기와 다르다" 읽는다.
그래서 여기서 DB 따로 읽지 않고 발행 파이프라인의 함수를 잇기만 한다.
일정(itineraries) payload 만들어도 여기선 내려보내지 않는다 캔버스에 그릴 자리가 아직 없다.
일정(itineraries) 함께 내려보낸다 캔버스 `ItineraryTickets` 섹션 데이터가 비었을
값으로 떨어진다(발행본 ItinerarySection 같은 폴백). 예전에는 그릴 자리가 없어 뺐다.
"""
# 순환 import 회피 — snapshot·site_payload 는 발행 파이프라인 모듈이라 서비스 최상단에서 끌어오지 않는다.
from services.site_payload import _local
@ -399,6 +400,7 @@ class LocalContentService:
res.restaurants = local.get("restaurants") or []
res.festivals = local.get("festivals") or []
res.courses = local.get("courses") or []
res.itineraries = local.get("itineraries") or []
res.synced_at = synced_at
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 .
* ****. + + .
* .
* ** ** (useLocalGuide).
* ItinerarySection "미리보기와 다르다" .
*/
import {
currentSeasons,
@ -15,6 +17,7 @@ import {
type ItineraryItem,
type PlannedStop,
} from '@o2o/shared';
import {useLocalGuide} from '@/hooks/useLocalGuide';
import {SectionBody, SectionFrame, Rail} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {
@ -32,11 +35,14 @@ import '../items/items.css';
export function ItineraryTickets(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
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 durations = itineraryDurations(parsed.items);
const durations = itineraryDurations(items);
const groups: (string | undefined)[] =
durations.length > 0
? [...durations, ...(parsed.items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
? [...durations, ...(items.some((i) => !i.duration?.trim()) ? [undefined] : [])]
: [undefined];
return (
@ -55,17 +61,17 @@ export function ItineraryTickets(props: SectionRenderProps) {
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
) : items.length === 0 ? (
<PasteHint label="추천 일정" />
) : (
<div className="space-y-7">
{groups.map((duration) => {
const items = parsed.items.filter((item) =>
const dayItems = items.filter((item) =>
duration === undefined
? !item.duration?.trim()
: item.duration?.trim() === duration,
);
if (items.length === 0) return null;
if (dayItems.length === 0) return null;
return (
<div key={duration ?? 'etc'} className="space-y-4">
{duration && (
@ -73,7 +79,7 @@ export function ItineraryTickets(props: SectionRenderProps) {
{duration}
</h3>
)}
{items.map((item, index) => (
{dayItems.map((item, index) => (
<div key={`${item.name}-${index}`} className="space-y-2.5">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h4 className="text-base font-bold" style={{fontFamily: ITEM_HEADING}}>

View File

@ -1,4 +1,5 @@
import {useEffect, useState} from 'react';
import type {ItineraryItem} from '@o2o/shared';
import type {FestivalCard, GuideCard} from '@/features/builder/canvas/variants/local/types';
import {useBuilderStore} from '@/stores/builder';
@ -6,11 +7,16 @@ export interface LocalGuideData {
foods: GuideCard[];
spots: GuideCard[];
festivals: FestivalCard[];
/**
* (12·23 5). ** **
* · (ItineraryItem) toCard .
*/
itineraries: ItineraryItem[];
/** 서버가 가장 최근에 수집한 시각(ISO). 아직 아무것도 없으면 undefined. */
syncedAt?: string;
}
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: []};
const EMPTY: LocalGuideData = {foods: [], spots: [], festivals: [], itineraries: []};
// 종류별 노출 상한. 서버(snapshot._LOCAL_MAX_PER_TYPE)가 같은 수로 자르지만, 화면이 먼저 넘치지 않게 여기서도 막는다.
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),
spots: ((data.attractions ?? []) as GuidePlace[]).slice(0, MAX_PER_TYPE).map(toCard),
festivals: ((data.festivals ?? []) as GuideFestival[]).slice(0, MAX_PER_TYPE).map(toFestival),
itineraries: Array.isArray(data.itineraries) ? data.itineraries : [],
syncedAt: data.synced_at ?? undefined,
};
// ★ 빈 응답은 캐시하지 않는다 — 수집 전에 열어둔 화면이 빈 결과를 물고 있으면