202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
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<string, unknown> {
|
||
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<string>()
|
||
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<string, ItinerarySource>()
|
||
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)
|
||
}
|