feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
15
services/travel-svc/Dockerfile
Normal file
15
services/travel-svc/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3004
|
||||
CMD ["node", "dist/index.js"]
|
||||
21
services/travel-svc/package.json
Normal file
21
services/travel-svc/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "travel-svc",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.1",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
270
services/travel-svc/src/index.ts
Normal file
270
services/travel-svc/src/index.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* travel-svc — Trending, Inspiration Cards, itineraries
|
||||
* docs/architecture: 01-perplexity-analogue-design.md §2.2.D
|
||||
* Redis: travel:trending, travel:inspiration, travel:itinerary:{hash}
|
||||
*/
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import Redis from 'ioredis';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3004', 10);
|
||||
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? '';
|
||||
const ITINERARY_TTL = 4 * 60 * 60; // 4h
|
||||
|
||||
// @ts-expect-error — ioredis + NodeNext ESM constructability
|
||||
const redis = new Redis(REDIS_URL);
|
||||
|
||||
const STUB_TRENDING = [
|
||||
{ id: 'paris', name: 'Paris', image: 'https://images.unsplash.com/photo-1502602898657-3e72660eb936?w=400', description: '' },
|
||||
{ id: 'tokyo', name: 'Tokyo', image: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=400', description: '' },
|
||||
];
|
||||
|
||||
const STUB_INSPIRATION = [
|
||||
{ title: 'Best hiking trails in Slovenia', summary: '', image: '', url: '#' },
|
||||
];
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
app.get('/metrics', async (_req, reply) => {
|
||||
reply.header('Content-Type', 'text/plain; charset=utf-8');
|
||||
return reply.send('# HELP gooseek_up Service is up (1) or down (0)\n# TYPE gooseek_up gauge\ngooseek_up 1\n');
|
||||
});
|
||||
app.get('/ready', async () => {
|
||||
try {
|
||||
await redis.ping();
|
||||
return { status: 'ready' };
|
||||
} catch {
|
||||
return { status: 'degraded' };
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/v1/travel/trending', async (req, reply) => {
|
||||
const cached = await redis.get('travel:trending');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
return reply.send({ items: STUB_TRENDING });
|
||||
});
|
||||
|
||||
app.get('/api/v1/travel/inspiration', async (req, reply) => {
|
||||
const cached = await redis.get('travel:inspiration');
|
||||
if (cached) return reply.send(JSON.parse(cached));
|
||||
return reply.send({ items: STUB_INSPIRATION });
|
||||
});
|
||||
|
||||
interface ItineraryDay {
|
||||
day: number;
|
||||
title: string;
|
||||
activities: string[];
|
||||
tips?: string;
|
||||
}
|
||||
|
||||
interface ItineraryPayload {
|
||||
days: ItineraryDay[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
async function generateItinerary(query: string, days: number): Promise<ItineraryPayload | null> {
|
||||
if (!OPENAI_API_KEY || !query.trim()) return null;
|
||||
try {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a travel planner. Generate a ${days}-day itinerary in JSON format.
|
||||
Return ONLY valid JSON, no markdown. Format:
|
||||
{ "days": [ { "day": 1, "title": "Day 1: Arrival", "activities": ["Activity 1", "Activity 2"], "tips": "Optional tip" } ], "summary": "Brief overview" }`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Create a ${days}-day itinerary for: ${query.trim()}. Include specific places, restaurants, and practical tips.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 2000,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const text = data?.choices?.[0]?.message?.content?.trim();
|
||||
if (!text) return null;
|
||||
const cleaned = text.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '');
|
||||
const parsed = JSON.parse(cleaned) as ItineraryPayload;
|
||||
if (!Array.isArray(parsed?.days) || parsed.days.length === 0) return null;
|
||||
return {
|
||||
days: parsed.days.map((d) => ({
|
||||
day: Number(d.day) || 0,
|
||||
title: String(d.title ?? ''),
|
||||
activities: Array.isArray(d.activities) ? d.activities.map(String) : [],
|
||||
tips: d.tips ? String(d.tips) : undefined,
|
||||
})),
|
||||
summary: parsed.summary ? String(parsed.summary) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.post<{ Body: unknown }>('/api/v1/travel/itinerary', async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { query?: string; days?: number };
|
||||
const query = typeof body?.query === 'string' ? body.query.trim() : '';
|
||||
const days = Math.min(14, Math.max(1, Number(body?.days) || 3));
|
||||
if (!query) {
|
||||
return reply.status(400).send({ error: 'query is required' });
|
||||
}
|
||||
const hash = crypto.createHash('sha256').update(JSON.stringify({ query, days })).digest('hex').slice(0, 16);
|
||||
const cacheKey = `travel:itinerary:${hash}`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return reply.send(JSON.parse(cached));
|
||||
}
|
||||
const payload = await generateItinerary(query, days);
|
||||
if (payload) {
|
||||
await redis.setex(cacheKey, ITINERARY_TTL, JSON.stringify(payload));
|
||||
return reply.send(payload);
|
||||
}
|
||||
return reply.send({
|
||||
message: 'Itinerary generation requires OPENAI_API_KEY. Configure it in travel-svc env.',
|
||||
days: [],
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to generate itinerary' });
|
||||
}
|
||||
});
|
||||
|
||||
// Stepper state — сохранение между шагами (Поиск → Места → Маршрут → Отели → Билеты)
|
||||
// Redis: travel:stepper:{sessionId} TTL 24h
|
||||
const STEPPER_TTL = 86400; // 24h
|
||||
|
||||
app.post('/api/v1/travel/stepper/state', async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { sessionId?: string; state?: Record<string, unknown> };
|
||||
const sessionId = body?.sessionId;
|
||||
const state = body?.state;
|
||||
if (!sessionId || typeof sessionId !== 'string' || !state || typeof state !== 'object') {
|
||||
return reply.status(400).send({ error: 'sessionId and state required' });
|
||||
}
|
||||
const key = `travel:stepper:${sessionId}`;
|
||||
await redis.setex(key, STEPPER_TTL, JSON.stringify(state));
|
||||
return reply.send({ ok: true });
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to save stepper state' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { sessionId: string } }>('/api/v1/travel/stepper/state/:sessionId', async (req, reply) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const key = `travel:stepper:${sessionId}`;
|
||||
const raw = await redis.get(key);
|
||||
if (!raw) return reply.send({ state: null });
|
||||
const state = JSON.parse(raw) as Record<string, unknown>;
|
||||
return reply.send({ state });
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ error: 'Failed to load stepper state' });
|
||||
}
|
||||
});
|
||||
|
||||
// Weather widget — proxy к Open-Meteo (перенесено из web-svc)
|
||||
async function geocodeCity(city: string): Promise<{ lat: number; lng: number; name: string } | null> {
|
||||
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const data = (await res.json()) as { results?: { latitude?: number; longitude?: number; name?: string }[] };
|
||||
const result = data.results?.[0];
|
||||
if (!result?.latitude || !result?.longitude) return null;
|
||||
return { lat: result.latitude, lng: result.longitude, name: result.name ?? city };
|
||||
}
|
||||
|
||||
app.post<{ Body: unknown }>('/api/v1/weather', async (req, reply) => {
|
||||
try {
|
||||
const body = req.body as { lat?: number; lng?: number; city?: string; measureUnit?: 'Imperial' | 'Metric' };
|
||||
let lat: number;
|
||||
let lng: number;
|
||||
let cityName: string | undefined = body.city;
|
||||
|
||||
if (body.lat != null && body.lng != null) {
|
||||
lat = body.lat;
|
||||
lng = body.lng;
|
||||
} else if (body.city?.trim()) {
|
||||
const geo = await geocodeCity(body.city.trim());
|
||||
if (!geo) return reply.status(404).send({ message: 'City not found.' });
|
||||
lat = geo.lat;
|
||||
lng = geo.lng;
|
||||
cityName = geo.name;
|
||||
} else {
|
||||
return reply.status(400).send({ message: 'Invalid request. Provide lat/lng or city.' });
|
||||
}
|
||||
|
||||
const unit = body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit';
|
||||
const windUnit = body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph';
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lng}¤t=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${unit}${windUnit}`,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
const data = (await res.json()) as { error?: boolean; reason?: string; current?: Record<string, number> };
|
||||
|
||||
if (data.error) {
|
||||
return reply.status(500).send({ message: data.reason ?? 'Weather fetch failed' });
|
||||
}
|
||||
|
||||
const code = data.current?.weather_code ?? 0;
|
||||
const isDay = data.current?.is_day === 1;
|
||||
const dayOrNight = isDay ? 'day' : 'night';
|
||||
const iconMap: Record<number, string> = {
|
||||
0: `clear-${dayOrNight}`, 2: `cloudy-1-${dayOrNight}`, 3: `cloudy-1-${dayOrNight}`,
|
||||
45: `fog-${dayOrNight}`, 48: `fog-${dayOrNight}`, 55: `rainy-1-${dayOrNight}`,
|
||||
57: `frost-${dayOrNight}`, 65: `rainy-2-${dayOrNight}`, 67: 'rain-and-sleet-mix',
|
||||
75: `snowy-2-${dayOrNight}`, 77: `snowy-1-${dayOrNight}`, 82: `rainy-3-${dayOrNight}`,
|
||||
87: `snowy-3-${dayOrNight}`, 95: `scattered-thunderstorms-${dayOrNight}`,
|
||||
99: 'severe-thunderstorm',
|
||||
};
|
||||
const condMap: Record<number, string> = {
|
||||
0: 'Clear', 1: 'Mainly Clear', 2: 'Partly Cloudy', 3: 'Cloudy', 45: 'Fog', 48: 'Fog',
|
||||
51: 'Light Drizzle', 53: 'Moderate Drizzle', 55: 'Dense Drizzle', 56: 'Light Freezing Drizzle',
|
||||
57: 'Dense Freezing Drizzle', 61: 'Slight Rain', 63: 'Moderate Rain', 65: 'Heavy Rain',
|
||||
66: 'Light Freezing Rain', 67: 'Heavy Freezing Rain', 71: 'Slight Snow Fall',
|
||||
73: 'Moderate Snow Fall', 75: 'Heavy Snow Fall', 77: 'Snow', 80: 'Slight Rain Showers',
|
||||
81: 'Moderate Rain Showers', 82: 'Heavy Rain Showers', 85: 'Slight Snow Showers',
|
||||
86: 'Moderate Snow Showers', 87: 'Heavy Snow Showers', 95: 'Thunderstorm',
|
||||
96: 'Thunderstorm with Slight Hail', 99: 'Thunderstorm with Heavy Hail',
|
||||
};
|
||||
return reply.send({
|
||||
temperature: data.current?.temperature_2m ?? 0,
|
||||
condition: condMap[code] ?? 'Clear',
|
||||
humidity: data.current?.relative_humidity_2m ?? 0,
|
||||
windSpeed: data.current?.wind_speed_10m ?? 0,
|
||||
icon: iconMap[code] ?? `clear-${dayOrNight}`,
|
||||
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
|
||||
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
|
||||
city: cityName,
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.error(err);
|
||||
return reply.status(500).send({ message: 'An error has occurred.' });
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`travel-svc listening on :${PORT}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
13
services/travel-svc/tsconfig.json
Normal file
13
services/travel-svc/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user