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:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,16 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3015
ENV PORT=3015
CMD ["node", "dist/index.js"]

View File

@@ -0,0 +1,22 @@
{
"name": "api-gateway",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts"
},
"dependencies": {
"dotenv": "^16.4.5",
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,156 @@
import { config } from 'dotenv';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
config({ path: path.resolve(fileURLToPath(import.meta.url), '../../../../.env') });
/**
* api-gateway — прокси к микросервисам
* web-svc = только UI, вся логика и API здесь
* docs/architecture: 02-k3s-microservices-spec.md §5
*/
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import cors from '@fastify/cors';
const PORT = parseInt(process.env.PORT ?? '3015', 10);
const SVC: Record<string, string> = {
chat: process.env.CHAT_SVC_URL ?? 'http://localhost:3005',
library: process.env.LIBRARY_SVC_URL ?? 'http://localhost:3009',
discover: process.env.DISCOVER_SVC_URL ?? 'http://localhost:3002',
search: process.env.SEARCH_SVC_URL ?? 'http://localhost:3001',
finance: process.env.FINANCE_SVC_URL ?? 'http://localhost:3003',
travel: process.env.TRAVEL_SVC_URL ?? 'http://localhost:3004',
create: process.env.CREATE_SVC_URL ?? 'http://localhost:3011',
memory: process.env.MEMORY_SVC_URL ?? 'http://localhost:3010',
projects: process.env.PROJECTS_SVC_URL ?? 'http://localhost:3006',
notifications: process.env.NOTIFICATIONS_SVC_URL ?? 'http://localhost:3013',
billing: process.env.BILLING_SVC_URL ?? 'http://localhost:3008',
audit: process.env.AUDIT_SVC_URL ?? 'http://localhost:3012',
auth: process.env.AUTH_SVC_URL ?? 'http://localhost:3014',
localization: process.env.LOCALIZATION_SVC_URL ?? 'http://localhost:4003',
geo: process.env.GEO_DEVICE_URL ?? 'http://localhost:4002',
media: process.env.MEDIA_SVC_URL ?? 'http://localhost:3016',
suggestions: process.env.SUGGESTIONS_SVC_URL ?? 'http://localhost:3017',
agents: process.env.MASTER_AGENTS_SVC_URL ?? 'http://localhost:3018',
profile: process.env.PROFILE_SVC_URL ?? 'http://localhost:3019',
llm: process.env.LLM_SVC_URL ?? 'http://localhost:3020',
};
function getTarget(path: string): { base: string; rewrite: string } | null {
if (path === '/api/chat' || path.startsWith('/api/chat?')) return { base: SVC.chat, rewrite: '/api/v1/chat' };
if (path.startsWith('/api/v1/library')) return { base: SVC.library, rewrite: path };
if (path.startsWith('/api/v1/discover')) return { base: SVC.discover, rewrite: path };
if (path.startsWith('/api/v1/search')) return { base: SVC.search, rewrite: path };
if (path.startsWith('/api/v1/finance')) return { base: SVC.finance, rewrite: path };
if (path.startsWith('/api/v1/travel')) return { base: SVC.travel, rewrite: path };
if (path.startsWith('/api/v1/create') || path.startsWith('/api/v1/export')) return { base: SVC.create, rewrite: path };
if (path.startsWith('/api/v1/memory')) return { base: SVC.memory, rewrite: path };
if (path.startsWith('/api/v1/tasks')) return { base: SVC.chat, rewrite: path };
if (path.startsWith('/api/v1/templates') || path.startsWith('/api/v1/connectors') || path.startsWith('/api/v1/collections')) return { base: SVC.projects, rewrite: path };
if (path.startsWith('/api/v1/notifications')) return { base: SVC.notifications, rewrite: path };
if (path.startsWith('/api/v1/billing')) return { base: SVC.billing, rewrite: path };
if (path.startsWith('/api/v1/admin')) return { base: SVC.audit, rewrite: path };
if (path.startsWith('/api/auth')) return { base: SVC.auth, rewrite: path };
if (path.startsWith('/api/locale') || path.startsWith('/api/translations')) return { base: SVC.localization, rewrite: path };
if (path.startsWith('/api/geo-context')) return { base: SVC.geo, rewrite: '/api/context' };
if (path === '/api/images') return { base: SVC.media, rewrite: '/api/v1/media/images' };
if (path === '/api/videos') return { base: SVC.media, rewrite: '/api/v1/media/videos' };
if (path === '/api/suggestions') return { base: SVC.suggestions, rewrite: '/api/v1/suggestions' };
if (path.startsWith('/api/v1/agents')) return { base: SVC.agents, rewrite: path };
if (path.startsWith('/api/v1/config')) return { base: SVC.chat, rewrite: path };
if (path.startsWith('/api/v1/providers')) return { base: SVC.llm, rewrite: path };
if (path.startsWith('/api/v1/uploads')) return { base: SVC.chat, rewrite: path };
if (path.startsWith('/api/v1/weather')) return { base: SVC.travel, rewrite: path };
if (path.startsWith('/api/v1/profile')) return { base: SVC.profile, rewrite: path };
return null;
}
async function proxyRequest(req: FastifyRequest, reply: FastifyReply, stream = false) {
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
const target = getTarget(path);
if (!target) {
return reply.status(404).send({ error: 'Not found' });
}
const fullUrl = `${target.base.replace(/\/$/, '')}${target.rewrite}${url.search}`;
const headers: Record<string, string> = {};
const pass = ['authorization', 'content-type', 'accept', 'x-forwarded-for', 'x-real-ip', 'user-agent', 'accept-language'];
for (const h of pass) {
const v = req.headers[h];
if (v && typeof v === 'string') headers[h] = v;
}
if (!headers['Content-Type'] && req.method !== 'GET') headers['Content-Type'] = 'application/json';
try {
const method = req.method;
let body: string | undefined;
if (method !== 'GET' && method !== 'HEAD') {
const b = (req as any).body;
body = typeof b === 'string' ? b : (b != null ? JSON.stringify(b) : '');
}
const init: RequestInit = {
method,
headers,
body,
signal: AbortSignal.timeout(path === '/api/chat' ? 120000 : 30000),
};
const res = await fetch(fullUrl, init);
const passthroughHeaders = [
'content-type',
'content-disposition',
'cache-control',
'connection',
'set-auth-token', // better-auth bearer token для API
'set-cookie', // better-auth session cookies
];
for (const [k, v] of res.headers.entries()) {
const l = k.toLowerCase();
if (passthroughHeaders.includes(l)) {
reply.header(k, v);
}
}
reply.status(res.status);
if (stream && res.body) {
return reply.send(res.body as any);
}
const data = await res.text();
const ct = res.headers.get('content-type') ?? '';
if (ct.includes('application/json')) {
try {
return reply.send(JSON.parse(data));
} catch {
return reply.send(data);
}
}
return reply.send(data);
} catch (err: unknown) {
req.log.error(err);
return reply.status(503).send({ error: 'Service unavailable' });
}
}
const app = Fastify({ logger: true });
const corsOrigin = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
.map((s) => s.trim())
.filter(Boolean)
: true;
await app.register(cors, { origin: corsOrigin });
app.get('/health', async () => ({ status: 'ok' }));
app.get('/ready', async () => ({ status: 'ready' }));
app.post('/api/chat', async (req, reply) => proxyRequest(req, reply, true));
app.all('/api/*', async (req, reply) => proxyRequest(req, reply, false));
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`api-gateway listening on :${PORT}`);
} catch (err) {
console.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}