Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
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)
}