import type { DurationFitStatus, Itinerary, ItineraryItem, ItinerarySource, PlanningMode, PlanResponse, TravelAssistantSource, } from './types' import type { PoiRepository } from '@/domain/poi' import { resolveCanonicalPoiId } from './poi-id' import { TravelValidationError } from './preferences' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function readString( value: unknown, fieldName: string, options: { allowEmpty?: boolean, maxLength?: number } = {}, ): string { if (typeof value !== 'string') throw new TravelValidationError(`${fieldName}格式无效`) const result = value.trim() if (!options.allowEmpty && !result) throw new TravelValidationError(`${fieldName}不能为空`) if (options.maxLength && result.length > options.maxLength) throw new TravelValidationError(`${fieldName}内容过长`) return result } function readStringArray(value: unknown, fieldName: string, maxItems: number): string[] { if (!Array.isArray(value) || value.length > maxItems) throw new TravelValidationError(`${fieldName}格式无效`) return value.map(item => readString(item, fieldName, { maxLength: 300 })) } function readInteger(value: unknown, fieldName: string, min: number, max: number): number { if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) throw new TravelValidationError(`${fieldName}格式无效`) return value as number } function readPlanningMode(value: unknown): PlanningMode { if (value !== 'quick' && value !== 'custom') throw new TravelValidationError('规划模式无效') return value } function readDurationFitStatus(value: unknown): DurationFitStatus { if (value !== 'fits' && value !== 'underfilled' && value !== 'overflow') throw new TravelValidationError('行程时长适配状态无效') return value } function readBoolean(value: unknown, fieldName: string): boolean { if (typeof value !== 'boolean') throw new TravelValidationError(`${fieldName}格式无效`) return value } function readClock(value: unknown, fieldName: string): string { const result = readString(value, fieldName) const match = /^(\d{2}):(\d{2})$/.exec(result) if (!match || Number(match[1]) > 47 || Number(match[2]) > 59) throw new TravelValidationError(`${fieldName}必须为 HH:mm`) return result } function minutesFromClock(value: string): number { const [hours, minutes] = value.split(':').map(Number) return hours * 60 + minutes } function normalizeItem(value: unknown, repository: PoiRepository): ItineraryItem { if (!isRecord(value)) throw new TravelValidationError('行程地点格式无效') const rawPlaceId = readString(value.placeId, '地点 ID') const placeId = resolveCanonicalPoiId(rawPlaceId, repository) if (!placeId) throw new TravelValidationError(`行程包含无法映射的地点 ID:${rawPlaceId}`) const poi = repository.getPoiById(placeId) if (!poi) throw new TravelValidationError(`行程地点已失效:${placeId}`) const startTime = readClock(value.startTime, '开始时间') const endTime = readClock(value.endTime, '结束时间') if (minutesFromClock(endTime) <= minutesFromClock(startTime)) throw new TravelValidationError(`行程地点时间无效:${poi.name}`) const transfer = value.transferFromPreviousMinutes if (transfer !== null && (!Number.isInteger(transfer) || (transfer as number) < 0 || (transfer as number) > 240)) throw new TravelValidationError(`交通时间格式无效:${poi.name}`) return { startTime, endTime, placeId, placeName: poi.name, activity: readString(value.activity, '游玩安排', { maxLength: 300 }), reason: readString(value.reason, '推荐理由', { maxLength: 500 }), transferFromPreviousMinutes: transfer as number | null, tips: readStringArray(value.tips, '出行提示', 10), } } function normalizeSource(value: unknown, repository: PoiRepository): ItinerarySource { if (!isRecord(value)) throw new TravelValidationError('行程来源格式无效') const rawPlaceId = readString(value.placeId, '来源地点 ID') const placeId = resolveCanonicalPoiId(rawPlaceId, repository) if (!placeId) throw new TravelValidationError(`行程来源包含无法映射的地点 ID:${rawPlaceId}`) return { placeId, sourceName: readString(value.sourceName, '来源名称', { maxLength: 300 }), sourceUrl: readString(value.sourceUrl, '来源链接', { allowEmpty: true, maxLength: 1000 }), updatedAt: readString(value.updatedAt, '来源更新时间', { maxLength: 100 }), } } function normalizeItinerary(value: unknown, repository: PoiRepository): Itinerary { if (!isRecord(value)) throw new TravelValidationError('行程格式无效') if (!Array.isArray(value.items) || value.items.length < 1 || value.items.length > 8) throw new TravelValidationError('行程地点数量必须为 1 至 8 个') if (!Array.isArray(value.sources)) throw new TravelValidationError('行程来源格式无效') const items = value.items.map(item => normalizeItem(item, repository)) const itemIds = new Set() let previousEnd = -1 for (const item of items) { if (itemIds.has(item.placeId)) throw new TravelValidationError(`行程地点重复:${item.placeName}`) itemIds.add(item.placeId) const start = minutesFromClock(item.startTime) if (start < previousEnd) throw new TravelValidationError('行程时间顺序无效') previousEnd = minutesFromClock(item.endTime) } const sourcesById = new Map() value.sources.forEach((source) => { const normalized = normalizeSource(source, repository) if (!itemIds.has(normalized.placeId)) throw new TravelValidationError(`行程来源包含未使用地点:${normalized.placeId}`) if (!sourcesById.has(normalized.placeId)) sourcesById.set(normalized.placeId, normalized) }) itemIds.forEach((placeId) => { if (!sourcesById.has(placeId)) throw new TravelValidationError(`行程地点缺少来源:${placeId}`) }) return { title: readString(value.title, '行程标题', { maxLength: 200 }), summary: readString(value.summary, '行程摘要', { maxLength: 1000 }), totalMinutes: readInteger(value.totalMinutes, '行程总时长', 1, 2880), planningMode: readPlanningMode(value.planningMode), requestedMinutes: readInteger(value.requestedMinutes, '用户选择时长', 120, 600), durationFitStatus: readDurationFitStatus(value.durationFitStatus), startsFromCurrentLocation: readBoolean(value.startsFromCurrentLocation, '当前位置出发标记'), estimatedCostText: readString(value.estimatedCostText, '费用说明', { maxLength: 500 }), items, notes: readStringArray(value.notes, '行程说明', 20), sources: [...sourcesById.values()], } } export function normalizePlanResponse( value: unknown, repository: PoiRepository, sourceOverride?: TravelAssistantSource, ): PlanResponse { if (!isRecord(value)) throw new TravelValidationError('行程响应格式无效') const source = sourceOverride ?? value.source if (source !== 'local_poc' && source !== 'remote_mock' && source !== 'remote_ai') throw new TravelValidationError('行程生成来源无效') const changeSummary = value.changeSummary if (changeSummary !== undefined && changeSummary !== null && typeof changeSummary !== 'string') throw new TravelValidationError('调整摘要格式无效') return { conversationId: readString(value.conversationId, '会话 ID', { maxLength: 200 }), assistantMessage: readString(value.assistantMessage, '助手消息', { maxLength: 1000 }), itinerary: normalizeItinerary(value.itinerary, repository), changeSummary: typeof changeSummary === 'string' ? changeSummary.trim() : changeSummary, source, } } export function clonePlanResponse(plan: PlanResponse, repository: PoiRepository): PlanResponse { return normalizePlanResponse(plan, repository) }