import type { PlanningRequest } from '@/domain/travel' interface RemoteAPIError { detail?: unknown } export interface RemotePoiCandidate { id: string name: string categoryCode: string tagCodes: string[] summary: string recommendationIndex: number } export interface RemotePoiCandidateInput { id: string name: string categoryCode: string tagCodes: readonly string[] summary: string recommendationIndex: number } export interface RemoteRecommendationItem { poiId: string reason: string order: number } export interface RemoteRecommendationResponse { requestId: string mode: PlanningRequest['mode'] assistantMessage: string recommendations: RemoteRecommendationItem[] generationMode: 'mock' | 'real' } function apiBaseUrl(): string { const value = import.meta.env.VITE_TRAVEL_ASSISTANT_API_BASE_URL return typeof value === 'string' ? value.trim().replace(/\/+$/, '') : '' } export function isRemoteTravelAssistantEnabled(): boolean { return apiBaseUrl().length > 0 } function remoteUrl(path: string): string { const baseUrl = apiBaseUrl() if (!/^https?:\/\//i.test(baseUrl)) throw new Error('VITE_TRAVEL_ASSISTANT_API_BASE_URL 必须是有效的 HTTP(S) 地址') return `${baseUrl}${path}` } function errorDetail(data: unknown, statusCode: number): string { if (typeof data === 'object' && data !== null) { const detail = (data as RemoteAPIError).detail if (typeof detail === 'string' && detail.trim()) return detail.trim() } return `行程服务请求失败(${statusCode})` } function request(options: UniApp.RequestOptions): Promise { return new Promise((resolve, reject) => { uni.request({ ...options, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { resolve(response.data as T) return } reject(new Error(errorDetail(response.data, response.statusCode))) }, fail() { reject(new Error('无法连接行程服务,请检查网络、服务地址和微信合法域名配置')) }, }) }) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function readNonEmptyString(value: unknown, fieldName: string, maxLength: number): string { if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) throw new Error(`AI 推荐响应中的${fieldName}格式无效`) return value.trim() } function safeCandidates(candidates: readonly RemotePoiCandidateInput[]): RemotePoiCandidate[] { if (candidates.length < 1 || candidates.length > 100) throw new Error('发送给 AI 的 POI 候选数量必须为 1 至 100 个') const ids = new Set() return candidates.map((candidate) => { const id = candidate.id.trim() if (!id || ids.has(id)) throw new Error(`发送给 AI 的 POI ID 无效或重复:${id || '空值'}`) const name = candidate.name.trim() const categoryCode = candidate.categoryCode.trim() const summary = candidate.summary.trim() if (!name || name.length > 100) throw new Error(`发送给 AI 的 POI 名称无效:${id}`) if (!categoryCode || categoryCode.length > 64) throw new Error(`发送给 AI 的 POI 分类无效:${id}`) if (summary.length > 300) throw new Error(`发送给 AI 的 POI 摘要过长:${id}`) if (!Number.isInteger(candidate.recommendationIndex) || candidate.recommendationIndex < 1 || candidate.recommendationIndex > 5) { throw new Error(`发送给 AI 的 POI 推荐指数无效:${id}`) } const tagCodes = [...new Set(candidate.tagCodes.map(tagCode => tagCode.trim()).filter(Boolean))] if (tagCodes.length > 20 || tagCodes.some(tagCode => tagCode.length > 64)) throw new Error(`发送给 AI 的 POI 标签无效:${id}`) ids.add(id) return { id, name, categoryCode, tagCodes, summary, recommendationIndex: candidate.recommendationIndex, } }) } function normalizeRecommendations( value: unknown, planningRequest: PlanningRequest, candidates: readonly RemotePoiCandidate[], ): RemoteRecommendationResponse { if (!isRecord(value)) throw new Error('AI 推荐响应格式无效') if (value.mode !== planningRequest.mode) throw new Error('AI 推荐响应的规划模式不匹配') if (value.generationMode !== 'mock' && value.generationMode !== 'real') throw new Error('AI 推荐响应的生成模式无效') if (!Array.isArray(value.recommendations) || value.recommendations.length < 1 || value.recommendations.length > 8) throw new Error('AI 推荐响应必须包含 1 至 8 个 POI') const allowedIds = new Set(candidates.map(candidate => candidate.id)) const seenIds = new Set() const seenOrders = new Set() const recommendations = value.recommendations.map((item): RemoteRecommendationItem => { if (!isRecord(item)) throw new Error('AI 推荐项格式无效') const poiId = readNonEmptyString(item.poiId, 'POI ID', 100) if (!allowedIds.has(poiId)) throw new Error(`AI 返回了候选白名单外的 POI:${poiId}`) if (seenIds.has(poiId)) throw new Error(`AI 返回了重复 POI:${poiId}`) if (!Number.isInteger(item.order) || (item.order as number) < 1 || (item.order as number) > 8) throw new Error('AI 推荐响应中的顺序格式无效') if (seenOrders.has(item.order as number)) throw new Error('AI 推荐响应中存在重复顺序') seenIds.add(poiId) seenOrders.add(item.order as number) return { poiId, reason: readNonEmptyString(item.reason, '推荐理由', 300), order: item.order as number, } }).sort((left, right) => left.order - right.order) if (recommendations.some((item, index) => item.order !== index + 1)) throw new Error('AI 推荐响应中的顺序必须从 1 连续递增') if (planningRequest.mode === 'custom') { const selectedIds = new Set(planningRequest.selectedPoiIds) if (recommendations.length !== selectedIds.size || recommendations.some(item => !selectedIds.has(item.poiId))) { throw new Error('AI 自选规划未完整保留用户选择的 POI') } } return { requestId: readNonEmptyString(value.requestId, '请求 ID', 200), mode: planningRequest.mode, assistantMessage: readNonEmptyString(value.assistantMessage, '助手消息', 1000), recommendations, generationMode: value.generationMode, } } /** * Requests coordinate-free POI recommendations. The caller must calculate the * final route and duration locally; planningRequest.origin is never uploaded. */ export async function createRemoteRecommendations( planningRequest: PlanningRequest, candidateInputs: readonly RemotePoiCandidateInput[], ): Promise { const candidates = safeCandidates(candidateInputs) const response = await request({ url: remoteUrl('/api/v1/plans'), method: 'POST', data: { mode: planningRequest.mode, preferences: { destination: planningRequest.preferences.destination, themes: [...planningRequest.preferences.themes], pace: planningRequest.preferences.pace, interests: [...planningRequest.preferences.interests], transport: planningRequest.preferences.transport, }, durationMinutes: planningRequest.durationMinutes, selectedPoiIds: [...planningRequest.selectedPoiIds], candidates, }, header: { 'content-type': 'application/json' }, timeout: 50000, }) return normalizeRecommendations(response, planningRequest, candidates) }