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/cache-worker/Dockerfile
Normal file
15
services/cache-worker/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 9090
|
||||
ENTRYPOINT ["node", "dist/run.js"]
|
||||
21
services/cache-worker/package.json
Normal file
21
services/cache-worker/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cache-worker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"discover": "tsx src/run.ts --task=discover",
|
||||
"finance": "tsx src/run.ts --task=finance",
|
||||
"travel": "tsx src/run.ts --task=travel",
|
||||
"all": "tsx src/run.ts --task=all",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"ioredis": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
49
services/cache-worker/src/run.ts
Normal file
49
services/cache-worker/src/run.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* cache-worker — pre-compute discover, finance, travel
|
||||
* docs/architecture: 03-cache-and-precompute-strategy.md
|
||||
* Cron: discover every 15m, finance every 2m, travel every 4h
|
||||
* Usage: node run.js --task=discover|finance|travel|all
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis';
|
||||
import { runDiscoverPrecompute } from './tasks/discover.js';
|
||||
import { runFinancePrecompute } from './tasks/finance.js';
|
||||
import { runTravelPrecompute } from './tasks/travel.js';
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
|
||||
const DISCOVER_SVC_URL = process.env.DISCOVER_SVC_URL ?? 'http://localhost:3002';
|
||||
const FINANCE_SVC_URL = process.env.FINANCE_SVC_URL ?? 'http://localhost:3003';
|
||||
const TRAVEL_SVC_URL = process.env.TRAVEL_SVC_URL ?? 'http://localhost:3004';
|
||||
|
||||
function getTask(): string {
|
||||
const idx = process.argv.indexOf('--task');
|
||||
return idx >= 0 && process.argv[idx + 1] ? process.argv[idx + 1] : 'all';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const task = getTask();
|
||||
// @ts-expect-error — ioredis + NodeNext ESM constructability
|
||||
const redis = new Redis(REDIS_URL);
|
||||
try {
|
||||
if (task === 'discover' || task === 'all') {
|
||||
console.log('[cache-worker] Running discover precompute...');
|
||||
await runDiscoverPrecompute(redis, DISCOVER_SVC_URL);
|
||||
}
|
||||
if (task === 'finance' || task === 'all') {
|
||||
console.log('[cache-worker] Running finance precompute...');
|
||||
await runFinancePrecompute(redis, FINANCE_SVC_URL);
|
||||
}
|
||||
if (task === 'travel' || task === 'all') {
|
||||
console.log('[cache-worker] Running travel precompute...');
|
||||
await runTravelPrecompute(redis, TRAVEL_SVC_URL);
|
||||
}
|
||||
console.log('[cache-worker] Done');
|
||||
} finally {
|
||||
await redis.quit();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
30
services/cache-worker/src/tasks/discover.ts
Normal file
30
services/cache-worker/src/tasks/discover.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Discover pre-compute: вызывает discover-svc для всех тем, заполняет Redis
|
||||
* Redis: discover:{topic} TTL 30 min
|
||||
*/
|
||||
|
||||
import type { Redis as RedisType } from 'ioredis';
|
||||
|
||||
const TOPICS = ['tech', 'finance', 'travel', 'world', 'science'];
|
||||
|
||||
export async function runDiscoverPrecompute(
|
||||
redis: RedisType,
|
||||
discoverSvcUrl: string
|
||||
): Promise<void> {
|
||||
for (const topic of TOPICS) {
|
||||
try {
|
||||
const url = `${discoverSvcUrl.replace(/\/$/, '')}/api/v1/discover?topic=${topic}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(60000) });
|
||||
if (!res.ok) {
|
||||
console.warn(`[discover] ${topic}: HTTP ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
const key = `discover:${topic}`;
|
||||
await redis.setex(key, 30 * 60, JSON.stringify(data));
|
||||
console.log(`[discover] ${topic}: cached ${data?.items?.length ?? 0} items`);
|
||||
} catch (err) {
|
||||
console.error(`[discover] ${topic}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
55
services/cache-worker/src/tasks/finance.ts
Normal file
55
services/cache-worker/src/tasks/finance.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Finance pre-compute: summary, heatmap, news по топ-тикерам
|
||||
* Redis: finance:summary, finance:heatmap, finance:news:{ticker}
|
||||
*/
|
||||
|
||||
import type { Redis as RedisType } from 'ioredis';
|
||||
|
||||
const TOP_TICKERS = ['AAPL', 'TSLA', 'GOOGL', 'MSFT', 'AMZN', 'NVDA', 'META'];
|
||||
|
||||
export async function runFinancePrecompute(
|
||||
redis: RedisType,
|
||||
financeSvcUrl: string
|
||||
): Promise<void> {
|
||||
const base = financeSvcUrl.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const summaryRes = await fetch(`${base}/api/v1/finance/summary`, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (summaryRes.ok) {
|
||||
const data = await summaryRes.json();
|
||||
await redis.setex('finance:summary', 5 * 60, JSON.stringify(data));
|
||||
console.log('[finance] summary: cached');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[finance] summary:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
const heatmapRes = await fetch(`${base}/api/v1/finance/heatmap`, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (heatmapRes.ok) {
|
||||
const data = await heatmapRes.json();
|
||||
await redis.setex('finance:heatmap', 5 * 60, JSON.stringify(data));
|
||||
console.log('[finance] heatmap: cached');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[finance] heatmap:', err);
|
||||
}
|
||||
|
||||
for (const ticker of TOP_TICKERS) {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/finance/news/${ticker}`, {
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await redis.setex(`finance:news:${ticker}`, 15 * 60, JSON.stringify(data));
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
103
services/cache-worker/src/tasks/travel.ts
Normal file
103
services/cache-worker/src/tasks/travel.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Travel pre-compute: trending, Inspiration Cards
|
||||
* Redis: travel:trending, travel:inspiration TTL 6h
|
||||
* docs/architecture: 03-cache-and-precompute-strategy.md §3
|
||||
* Inspiration: курируемые темы → LLM суммаризация (gpt-4o-mini)
|
||||
*/
|
||||
|
||||
import type { Redis as RedisType } from 'ioredis';
|
||||
|
||||
const INSPIRATION_TOPICS = [
|
||||
'best hiking trails Slovenia',
|
||||
'forest hotels Sweden solitude',
|
||||
'hidden beaches Portugal',
|
||||
'weekend getaways Europe',
|
||||
'road trips USA west',
|
||||
'food markets Istanbul',
|
||||
];
|
||||
|
||||
const STUB_INSPIRATION = [
|
||||
{ title: 'Best hiking trails in Slovenia', summary: 'Explore Triglav and the Julian Alps.', image: '', url: '#' },
|
||||
{ title: 'Forest hotels in Sweden', summary: 'Escape to Nordic solitude.', image: '', url: '#' },
|
||||
{ title: 'Hidden beaches in Portugal', summary: 'Algarve beyond the crowds.', image: '', url: '#' },
|
||||
];
|
||||
|
||||
async function generateInspirationCards(apiKey: string): Promise<{ title: string; summary: string; image?: string; url?: string }[]> {
|
||||
try {
|
||||
const topics = INSPIRATION_TOPICS.slice(0, 4).join(', ');
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Return ONLY valid JSON array. Each item: { "title": "short catchy title", "summary": "1-2 sentences" }. No markdown.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Create 4 travel inspiration cards for these topics: ${topics}. Return JSON array of 4 objects with title and summary.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 600,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!res.ok) return STUB_INSPIRATION;
|
||||
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const raw = data.choices?.[0]?.message?.content?.trim() ?? '';
|
||||
const cleaned = raw.replace(/^```json?\s*/i, '').replace(/\s*```$/i, '');
|
||||
const parsed = JSON.parse(cleaned) as { title?: string; summary?: string }[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.map((p) => ({
|
||||
title: String(p.title ?? ''),
|
||||
summary: String(p.summary ?? ''),
|
||||
image: '',
|
||||
url: '#',
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[travel] inspiration LLM:', err);
|
||||
}
|
||||
return STUB_INSPIRATION;
|
||||
}
|
||||
|
||||
export async function runTravelPrecompute(
|
||||
redis: RedisType,
|
||||
travelSvcUrl: string
|
||||
): Promise<void> {
|
||||
const base = travelSvcUrl.replace(/\/$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/travel/trending`, {
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
await redis.setex('travel:trending', 6 * 60 * 60, JSON.stringify(data));
|
||||
console.log('[travel] trending: cached');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[travel] trending:', err);
|
||||
}
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY ?? '';
|
||||
const inspiration = apiKey
|
||||
? await generateInspirationCards(apiKey)
|
||||
: STUB_INSPIRATION;
|
||||
try {
|
||||
await redis.setex(
|
||||
'travel:inspiration',
|
||||
6 * 60 * 60,
|
||||
JSON.stringify({ items: inspiration })
|
||||
);
|
||||
console.log('[travel] inspiration: cached', apiKey ? '(LLM)' : '(stub)');
|
||||
} catch (err) {
|
||||
console.error('[travel] inspiration:', err);
|
||||
}
|
||||
}
|
||||
13
services/cache-worker/tsconfig.json
Normal file
13
services/cache-worker/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