Files
gmTouringMiniApp/src/domain/travel/preferences.ts
T

119 lines
4.0 KiB
TypeScript

import {
type BudgetLevel,
type Duration,
type Interest,
type Pace,
type Theme,
type Transport,
TRAVEL_BUDGET_LEVELS,
TRAVEL_DURATIONS,
TRAVEL_INTERESTS,
TRAVEL_PACES,
TRAVEL_THEMES,
TRAVEL_TRANSPORTS,
type TravelPreferences,
} from './types'
const DESTINATION = '深圳市光明区' as const
export class TravelValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'TravelValidationError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isEnumValue<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && allowed.includes(value as T)
}
function normalizeEnumArray<T extends string>(
value: unknown,
allowed: readonly T[],
fieldName: string,
): T[] {
if (!Array.isArray(value) || value.some(item => !isEnumValue(item, allowed)))
throw new TravelValidationError(`${fieldName}包含无效选项`)
const selected = new Set(value as T[])
return allowed.filter(item => selected.has(item))
}
function normalizeInteger(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}必须是 ${min}${max} 的整数`)
return value as number
}
export function normalizeTravelPreferences(value: unknown): TravelPreferences {
if (!isRecord(value))
throw new TravelValidationError('出行偏好格式无效')
if (value.destination !== DESTINATION)
throw new TravelValidationError('当前仅支持深圳市光明区')
const themes = normalizeEnumArray<Theme>(value.themes, TRAVEL_THEMES, '出行主题')
const interests = normalizeEnumArray<Interest>(value.interests, TRAVEL_INTERESTS, '特色偏好')
if (themes.length === 0 && interests.length === 0)
throw new TravelValidationError('主题或特色偏好至少选择一项')
if (!isEnumValue<Duration>(value.duration, TRAVEL_DURATIONS))
throw new TravelValidationError('游玩时长无效')
if (!isEnumValue<Pace>(value.pace, TRAVEL_PACES))
throw new TravelValidationError('行程节奏无效')
if (!isEnumValue<Transport>(value.transport, TRAVEL_TRANSPORTS))
throw new TravelValidationError('交通方式无效')
if (!isEnumValue<BudgetLevel>(value.budgetLevel, TRAVEL_BUDGET_LEVELS))
throw new TravelValidationError('预算档位无效')
const adults = normalizeInteger(value.adults, '成人数量', 0, 20)
const children = normalizeInteger(value.children, '儿童数量', 0, 20)
if (adults + children < 1)
throw new TravelValidationError('出行总人数至少为 1')
if (!Array.isArray(value.childAges) || value.childAges.length !== children)
throw new TravelValidationError('儿童年龄数量必须与儿童人数一致')
const childAges = value.childAges.map(age => normalizeInteger(age, '儿童年龄', 0, 17))
if (typeof value.extraRequirements !== 'string')
throw new TravelValidationError('补充要求格式无效')
const extraRequirements = value.extraRequirements.trim()
if (extraRequirements.length > 500)
throw new TravelValidationError('补充要求不能超过 500 字')
return {
destination: DESTINATION,
themes,
duration: value.duration,
pace: value.pace,
interests,
adults,
children,
childAges,
transport: value.transport,
budgetLevel: value.budgetLevel,
extraRequirements,
}
}
export function isTravelPreferences(value: unknown): value is TravelPreferences {
try {
normalizeTravelPreferences(value)
return true
}
catch {
return false
}
}
export function cloneTravelPreferences(preferences: TravelPreferences): TravelPreferences {
const normalized = normalizeTravelPreferences(preferences)
return {
...normalized,
themes: [...normalized.themes],
interests: [...normalized.interests],
childAges: [...normalized.childAges],
}
}