Files
gmTouringMiniApp/src/domain/travel/plan-validation.ts
T

202 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}