Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
{
"name": "i18n-service",
"version": "1.0.0",
"description": "Internationalization service for multi-language support",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.5.0",
"dotenv": "^16.3.1",
"joi": "^17.10.2",
"winston": "^3.10.0",
"axios": "^1.5.0",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"express-rate-limit": "^6.10.0",
"i18next": "^23.5.1",
"i18next-node-fs-backend": "^2.1.3",
"detect-language": "^1.1.0",
"country-language": "^0.1.7",
"accept-language-parser": "^1.5.0"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.6.4",
"@types/jest": "^29.5.4"
}
}

View File

@@ -0,0 +1,57 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT || 3011,
mongodb: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/i18n_service'
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || '',
ttl: 3600 // 1 hour cache
},
cors: {
origin: process.env.CORS_ORIGINS ?
process.env.CORS_ORIGINS.split(',') :
['http://localhost:3000', 'http://localhost:8080'],
credentials: true
},
logging: {
level: process.env.LOG_LEVEL || 'info',
format: process.env.LOG_FORMAT || 'json'
},
i18n: {
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
supportedLanguages: (process.env.SUPPORTED_LANGUAGES || 'en,es,fr,de,zh,ja,ru,ar,pt,it').split(','),
translationNamespace: 'translation',
resourcePath: './locales'
},
translation: {
autoTranslate: process.env.AUTO_TRANSLATE === 'true',
translationProvider: process.env.TRANSLATION_PROVIDER || 'google', // google, deepl, openai
googleApiKey: process.env.GOOGLE_TRANSLATE_API_KEY,
deeplApiKey: process.env.DEEPL_API_KEY,
openaiApiKey: process.env.OPENAI_API_KEY,
cacheTranslations: true
},
detection: {
order: ['header', 'querystring', 'cookie', 'path'],
caches: ['cookie'],
cookieName: 'i18next',
lookupQuerystring: 'lang',
lookupCookie: 'i18next',
lookupHeader: 'accept-language',
lookupPath: 'lang'
}
};

View File

@@ -0,0 +1,83 @@
import express from 'express';
import mongoose from 'mongoose';
import cors from 'cors';
import helmet from 'helmet';
import { config } from './config/index.js';
import { logger } from './utils/logger.js';
import translationRoutes from './routes/translations.js';
import languageRoutes from './routes/languages.js';
import localizationRoutes from './routes/localization.js';
const app = express();
// Middleware
app.use(helmet());
app.use(cors(config.cors));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
query: req.query,
body: req.body,
headers: req.headers
});
next();
});
// Routes
app.use('/api/v1/translations', translationRoutes);
app.use('/api/v1/languages', languageRoutes);
app.use('/api/v1/localization', localizationRoutes);
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
service: 'i18n-service',
timestamp: new Date().toISOString()
});
});
// Error handling
app.use((err, req, res, next) => {
logger.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: 'Internal server error'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: 'Not found'
});
});
// Connect to MongoDB and start server
async function start() {
try {
await mongoose.connect(config.mongodb.uri, {
useNewUrlParser: true,
useUnifiedTopology: true
});
logger.info('Connected to MongoDB');
// Initialize default languages
const { Language } = await import('./models/Language.js');
await Language.initializeDefaults();
logger.info('Default languages initialized');
app.listen(config.port, () => {
logger.info(`I18n service listening on port ${config.port}`);
});
} catch (error) {
logger.error('Failed to start service:', error);
process.exit(1);
}
}
start();

View File

@@ -0,0 +1,226 @@
import mongoose from 'mongoose';
const languageSchema = new mongoose.Schema({
code: {
type: String,
required: true,
unique: true,
lowercase: true,
match: /^[a-z]{2}(-[A-Z]{2})?$/ // e.g., 'en' or 'en-US'
},
name: {
type: String,
required: true
},
nativeName: {
type: String,
required: true
},
direction: {
type: String,
enum: ['ltr', 'rtl'],
default: 'ltr'
},
enabled: {
type: Boolean,
default: true
},
isDefault: {
type: Boolean,
default: false
},
fallbackLanguage: {
type: String,
default: 'en'
},
pluralRules: {
type: String,
enum: ['one-other', 'zero-one-other', 'one-few-many-other', 'other'],
default: 'one-other'
},
dateFormat: {
type: String,
default: 'MM/DD/YYYY'
},
timeFormat: {
type: String,
default: 'h:mm A'
},
numberFormat: {
decimal: {
type: String,
default: '.'
},
thousand: {
type: String,
default: ','
},
currency: {
symbol: String,
position: {
type: String,
enum: ['before', 'after'],
default: 'before'
}
}
},
translationProgress: {
total: {
type: Number,
default: 0
},
translated: {
type: Number,
default: 0
},
verified: {
type: Number,
default: 0
},
lastUpdated: Date
},
metadata: {
flag: String, // Flag emoji or icon
region: String,
script: String,
variants: [String]
}
}, {
timestamps: true
});
// Ensure only one default language
languageSchema.pre('save', async function(next) {
if (this.isDefault && this.isModified('isDefault')) {
await mongoose.model('Language').updateMany(
{ _id: { $ne: this._id } },
{ isDefault: false }
);
}
next();
});
// Methods
languageSchema.methods.updateProgress = async function() {
const Translation = mongoose.model('Translation');
const total = await Translation.countDocuments({ language: this.code });
const translated = await Translation.countDocuments({
language: this.code,
'metadata.source': { $ne: 'auto' }
});
const verified = await Translation.countDocuments({
language: this.code,
'metadata.verified': true
});
this.translationProgress = {
total,
translated,
verified,
lastUpdated: new Date()
};
return this.save();
};
languageSchema.methods.getCompletionPercentage = function() {
if (this.translationProgress.total === 0) return 0;
return Math.round(
(this.translationProgress.translated / this.translationProgress.total) * 100
);
};
// Statics
languageSchema.statics.getEnabled = function() {
return this.find({ enabled: true }).sort('name');
};
languageSchema.statics.getDefault = function() {
return this.findOne({ isDefault: true });
};
languageSchema.statics.initializeDefaults = async function() {
const defaultLanguages = [
{
code: 'en',
name: 'English',
nativeName: 'English',
direction: 'ltr',
isDefault: true,
metadata: { flag: '🇺🇸' }
},
{
code: 'es',
name: 'Spanish',
nativeName: 'Español',
direction: 'ltr',
metadata: { flag: '🇪🇸' }
},
{
code: 'fr',
name: 'French',
nativeName: 'Français',
direction: 'ltr',
metadata: { flag: '🇫🇷' }
},
{
code: 'de',
name: 'German',
nativeName: 'Deutsch',
direction: 'ltr',
metadata: { flag: '🇩🇪' }
},
{
code: 'zh',
name: 'Chinese',
nativeName: '中文',
direction: 'ltr',
metadata: { flag: '🇨🇳' }
},
{
code: 'ja',
name: 'Japanese',
nativeName: '日本語',
direction: 'ltr',
metadata: { flag: '🇯🇵' }
},
{
code: 'ru',
name: 'Russian',
nativeName: 'Русский',
direction: 'ltr',
metadata: { flag: '🇷🇺' }
},
{
code: 'ar',
name: 'Arabic',
nativeName: 'العربية',
direction: 'rtl',
metadata: { flag: '🇸🇦' }
},
{
code: 'pt',
name: 'Portuguese',
nativeName: 'Português',
direction: 'ltr',
metadata: { flag: '🇵🇹' }
},
{
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
direction: 'ltr',
metadata: { flag: '🇮🇹' }
}
];
for (const lang of defaultLanguages) {
await this.findOneAndUpdate(
{ code: lang.code },
lang,
{ upsert: true, new: true }
);
}
};
export const Language = mongoose.model('Language', languageSchema);

View File

@@ -0,0 +1,134 @@
import mongoose from 'mongoose';
const translationSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
key: {
type: String,
required: true,
index: true
},
namespace: {
type: String,
default: 'translation',
index: true
},
language: {
type: String,
required: true,
index: true
},
value: {
type: String,
required: true
},
context: {
type: String,
index: true
},
pluralForm: {
type: String,
enum: ['zero', 'one', 'few', 'many', 'other']
},
metadata: {
source: {
type: String,
enum: ['manual', 'auto', 'import', 'api'],
default: 'manual'
},
translator: String,
translatedAt: Date,
verified: {
type: Boolean,
default: false
},
verifiedBy: String,
verifiedAt: Date,
tags: [String],
notes: String
},
usage: {
count: {
type: Number,
default: 0
},
lastUsed: Date,
contexts: [String]
},
isActive: {
type: Boolean,
default: true
}
}, {
timestamps: true
});
// Compound index for unique translations
translationSchema.index({ tenantId: 1, key: 1, namespace: 1, language: 1, context: 1 }, { unique: true });
// Text search index
translationSchema.index({ key: 'text', value: 'text' });
// Multi-tenant indexes
translationSchema.index({ tenantId: 1, key: 1, namespace: 1, language: 1, context: 1 }, { unique: true });
translationSchema.index({ tenantId: 1, key: 'text', value: 'text' });
// Methods
translationSchema.methods.incrementUsage = async function(context) {
this.usage.count += 1;
this.usage.lastUsed = new Date();
if (context && !this.usage.contexts.includes(context)) {
this.usage.contexts.push(context);
}
return this.save();
};
translationSchema.methods.verify = async function(verifiedBy) {
this.metadata.verified = true;
this.metadata.verifiedBy = verifiedBy;
this.metadata.verifiedAt = new Date();
return this.save();
};
// Statics
translationSchema.statics.findByKey = function(key, language, namespace = 'translation') {
return this.findOne({ key, language, namespace, isActive: true });
};
translationSchema.statics.findByLanguage = function(language, namespace = 'translation') {
return this.find({ language, namespace, isActive: true });
};
translationSchema.statics.getTranslations = async function(keys, language, namespace = 'translation') {
const translations = await this.find({
key: { $in: keys },
language,
namespace,
isActive: true
});
// Convert to key-value object
return translations.reduce((acc, trans) => {
acc[trans.key] = trans.value;
return acc;
}, {});
};
translationSchema.statics.getMissingTranslations = async function(language, namespace = 'translation') {
// Find all unique keys
const allKeys = await this.distinct('key', { namespace });
// Find existing translations for the language
const existingTranslations = await this.find({ language, namespace });
const existingKeys = existingTranslations.map(t => t.key);
// Return missing keys
return allKeys.filter(key => !existingKeys.includes(key));
};
export const Translation = mongoose.model('Translation', translationSchema);

View File

@@ -0,0 +1,304 @@
import express from 'express';
import Joi from 'joi';
import { Language } from '../models/Language.js';
import { localizationService } from '../services/localizationService.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Validation schemas
const languageSchema = Joi.object({
code: Joi.string().pattern(/^[a-z]{2}(-[A-Z]{2})?$/).required(),
name: Joi.string().required(),
nativeName: Joi.string().required(),
direction: Joi.string().valid('ltr', 'rtl').default('ltr'),
enabled: Joi.boolean().default(true),
isDefault: Joi.boolean().default(false),
fallbackLanguage: Joi.string().default('en'),
dateFormat: Joi.string(),
timeFormat: Joi.string(),
numberFormat: Joi.object({
decimal: Joi.string(),
thousand: Joi.string(),
currency: Joi.object({
symbol: Joi.string(),
position: Joi.string().valid('before', 'after')
})
}),
metadata: Joi.object({
flag: Joi.string(),
region: Joi.string()
})
});
/**
* GET /api/v1/languages
* Get all languages
*/
router.get('/', async (req, res) => {
try {
const { enabled } = req.query;
let query = {};
if (enabled !== undefined) {
query.enabled = enabled === 'true';
}
const languages = await Language.find(query).sort('name');
res.json({
success: true,
languages,
total: languages.length
});
} catch (error) {
logger.error('Failed to get languages:', error);
res.status(500).json({
success: false,
error: 'Failed to get languages'
});
}
});
/**
* GET /api/v1/languages/enabled
* Get enabled languages with completion status
*/
router.get('/enabled', async (req, res) => {
try {
const languages = await localizationService.getEnabledLanguages();
res.json({
success: true,
languages
});
} catch (error) {
logger.error('Failed to get enabled languages:', error);
res.status(500).json({
success: false,
error: 'Failed to get enabled languages'
});
}
});
/**
* GET /api/v1/languages/detect
* Detect user's preferred language
*/
router.get('/detect', async (req, res) => {
try {
const detectedLanguage = await localizationService.detectLanguage(req);
res.json({
success: true,
language: detectedLanguage,
headers: {
acceptLanguage: req.headers['accept-language'],
country: req.headers['cf-ipcountry']
}
});
} catch (error) {
logger.error('Failed to detect language:', error);
res.status(500).json({
success: false,
error: 'Failed to detect language'
});
}
});
/**
* GET /api/v1/languages/:code
* Get language by code
*/
router.get('/:code', async (req, res) => {
try {
const language = await Language.findOne({ code: req.params.code });
if (!language) {
return res.status(404).json({
success: false,
error: 'Language not found'
});
}
res.json({
success: true,
language
});
} catch (error) {
logger.error('Failed to get language:', error);
res.status(500).json({
success: false,
error: 'Failed to get language'
});
}
});
/**
* POST /api/v1/languages
* Create new language
*/
router.post('/', async (req, res) => {
try {
const { error, value } = languageSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const language = await Language.create(value);
res.status(201).json({
success: true,
language
});
} catch (error) {
if (error.code === 11000) {
return res.status(400).json({
success: false,
error: 'Language code already exists'
});
}
logger.error('Failed to create language:', error);
res.status(500).json({
success: false,
error: 'Failed to create language'
});
}
});
/**
* PUT /api/v1/languages/:code
* Update language
*/
router.put('/:code', async (req, res) => {
try {
const language = await Language.findOne({ code: req.params.code });
if (!language) {
return res.status(404).json({
success: false,
error: 'Language not found'
});
}
// Don't allow changing the code
delete req.body.code;
Object.assign(language, req.body);
await language.save();
res.json({
success: true,
language
});
} catch (error) {
logger.error('Failed to update language:', error);
res.status(500).json({
success: false,
error: 'Failed to update language'
});
}
});
/**
* PUT /api/v1/languages/:code/progress
* Update translation progress
*/
router.put('/:code/progress', async (req, res) => {
try {
const language = await Language.findOne({ code: req.params.code });
if (!language) {
return res.status(404).json({
success: false,
error: 'Language not found'
});
}
await language.updateProgress();
res.json({
success: true,
language,
progress: {
percentage: language.getCompletionPercentage(),
...language.translationProgress
}
});
} catch (error) {
logger.error('Failed to update progress:', error);
res.status(500).json({
success: false,
error: 'Failed to update progress'
});
}
});
/**
* POST /api/v1/languages/initialize
* Initialize default languages
*/
router.post('/initialize', async (req, res) => {
try {
await Language.initializeDefaults();
const languages = await Language.find();
res.json({
success: true,
message: 'Default languages initialized',
languages,
total: languages.length
});
} catch (error) {
logger.error('Failed to initialize languages:', error);
res.status(500).json({
success: false,
error: 'Failed to initialize languages'
});
}
});
/**
* DELETE /api/v1/languages/:code
* Delete language (disable it)
*/
router.delete('/:code', async (req, res) => {
try {
const language = await Language.findOne({ code: req.params.code });
if (!language) {
return res.status(404).json({
success: false,
error: 'Language not found'
});
}
if (language.isDefault) {
return res.status(400).json({
success: false,
error: 'Cannot delete default language'
});
}
language.enabled = false;
await language.save();
res.json({
success: true,
message: 'Language disabled successfully'
});
} catch (error) {
logger.error('Failed to delete language:', error);
res.status(500).json({
success: false,
error: 'Failed to delete language'
});
}
});
export default router;

View File

@@ -0,0 +1,261 @@
import express from 'express';
import { localizationService } from '../services/localizationService.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
/**
* POST /api/v1/localization/format/number
* Format number according to locale
*/
router.post('/format/number', async (req, res) => {
try {
const { number, language, options = {} } = req.body;
if (number === undefined || !language) {
return res.status(400).json({
success: false,
error: 'number and language are required'
});
}
const formatted = localizationService.formatNumber(
number,
language,
options
);
res.json({
success: true,
formatted,
original: number,
language
});
} catch (error) {
logger.error('Failed to format number:', error);
res.status(500).json({
success: false,
error: 'Failed to format number'
});
}
});
/**
* POST /api/v1/localization/format/currency
* Format currency according to locale
*/
router.post('/format/currency', async (req, res) => {
try {
const { amount, language, currency = 'USD' } = req.body;
if (amount === undefined || !language) {
return res.status(400).json({
success: false,
error: 'amount and language are required'
});
}
const formatted = localizationService.formatCurrency(
amount,
language,
currency
);
res.json({
success: true,
formatted,
original: amount,
language,
currency
});
} catch (error) {
logger.error('Failed to format currency:', error);
res.status(500).json({
success: false,
error: 'Failed to format currency'
});
}
});
/**
* POST /api/v1/localization/format/date
* Format date according to locale
*/
router.post('/format/date', async (req, res) => {
try {
const { date, language, options = {} } = req.body;
if (!date || !language) {
return res.status(400).json({
success: false,
error: 'date and language are required'
});
}
const formatted = localizationService.formatDate(
date,
language,
options
);
res.json({
success: true,
formatted,
original: date,
language
});
} catch (error) {
logger.error('Failed to format date:', error);
res.status(500).json({
success: false,
error: 'Failed to format date'
});
}
});
/**
* POST /api/v1/localization/format/relative-time
* Format relative time
*/
router.post('/format/relative-time', async (req, res) => {
try {
const { date, language } = req.body;
if (!date || !language) {
return res.status(400).json({
success: false,
error: 'date and language are required'
});
}
const formatted = localizationService.formatRelativeTime(
date,
language
);
res.json({
success: true,
formatted,
original: date,
language
});
} catch (error) {
logger.error('Failed to format relative time:', error);
res.status(500).json({
success: false,
error: 'Failed to format relative time'
});
}
});
/**
* POST /api/v1/localization/sort
* Sort array according to locale
*/
router.post('/sort', async (req, res) => {
try {
const { items, language, key } = req.body;
if (!items || !Array.isArray(items) || !language) {
return res.status(400).json({
success: false,
error: 'items (array) and language are required'
});
}
const sorted = localizationService.sortByLocale(
[...items], // Don't mutate original
language,
key
);
res.json({
success: true,
sorted,
original: items,
language
});
} catch (error) {
logger.error('Failed to sort items:', error);
res.status(500).json({
success: false,
error: 'Failed to sort items'
});
}
});
/**
* GET /api/v1/localization/direction/:language
* Get text direction for language
*/
router.get('/direction/:language', async (req, res) => {
try {
const direction = await localizationService.getTextDirection(
req.params.language
);
res.json({
success: true,
language: req.params.language,
direction
});
} catch (error) {
logger.error('Failed to get text direction:', error);
res.status(500).json({
success: false,
error: 'Failed to get text direction'
});
}
});
/**
* POST /api/v1/localization/batch-format
* Batch format multiple values
*/
router.post('/batch-format', async (req, res) => {
try {
const { items, language } = req.body;
if (!items || !Array.isArray(items) || !language) {
return res.status(400).json({
success: false,
error: 'items (array) and language are required'
});
}
const results = items.map(item => {
const { type, value, options = {} } = item;
switch (type) {
case 'number':
return localizationService.formatNumber(value, language, options);
case 'currency':
return localizationService.formatCurrency(
value,
language,
options.currency || 'USD'
);
case 'date':
return localizationService.formatDate(value, language, options);
case 'relative-time':
return localizationService.formatRelativeTime(value, language);
default:
return value;
}
});
res.json({
success: true,
results,
language
});
} catch (error) {
logger.error('Failed to batch format:', error);
res.status(500).json({
success: false,
error: 'Failed to batch format'
});
}
});
export default router;

View File

@@ -0,0 +1,349 @@
import express from 'express';
import Joi from 'joi';
import { Translation } from '../models/Translation.js';
import { translationService } from '../services/translationService.js';
import { logger } from '../utils/logger.js';
const router = express.Router();
// Validation schemas
const translationSchema = Joi.object({
key: Joi.string().required(),
language: Joi.string().required(),
value: Joi.string().required(),
namespace: Joi.string().default('translation'),
context: Joi.string().allow(null),
metadata: Joi.object({
notes: Joi.string(),
tags: Joi.array().items(Joi.string())
})
});
const bulkTranslationSchema = Joi.object({
translations: Joi.object().pattern(
Joi.string(),
Joi.string()
).required(),
language: Joi.string().required(),
namespace: Joi.string().default('translation'),
overwrite: Joi.boolean().default(false)
});
/**
* GET /api/v1/translations
* Get translation(s)
*/
router.get('/', async (req, res) => {
try {
const { key, keys, language, namespace = 'translation' } = req.query;
if (!language) {
return res.status(400).json({
success: false,
error: 'Language parameter is required'
});
}
// Single key
if (key) {
const translation = await translationService.getTranslation(
key,
language,
{ namespace }
);
return res.json({
success: true,
translation: {
key,
value: translation,
language,
namespace
}
});
}
// Multiple keys
if (keys) {
const keyArray = Array.isArray(keys) ? keys : keys.split(',');
const translations = await translationService.getTranslations(
keyArray,
language,
{ namespace }
);
return res.json({
success: true,
translations,
language,
namespace
});
}
// All translations for language
const translations = await Translation.find({
language,
namespace,
isActive: true
});
return res.json({
success: true,
translations,
total: translations.length
});
} catch (error) {
logger.error('Failed to get translations:', error);
res.status(500).json({
success: false,
error: 'Failed to get translations'
});
}
});
/**
* POST /api/v1/translations
* Create or update translation
*/
router.post('/', async (req, res) => {
try {
const { error, value } = translationSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const translation = await translationService.setTranslation(
value.key,
value.language,
value.value,
{
namespace: value.namespace,
context: value.context,
metadata: value.metadata
}
);
res.status(201).json({
success: true,
translation
});
} catch (error) {
logger.error('Failed to create translation:', error);
res.status(500).json({
success: false,
error: 'Failed to create translation'
});
}
});
/**
* POST /api/v1/translations/bulk
* Import multiple translations
*/
router.post('/bulk', async (req, res) => {
try {
const { error, value } = bulkTranslationSchema.validate(req.body);
if (error) {
return res.status(400).json({
success: false,
error: error.details[0].message
});
}
const results = await translationService.importTranslations(
value.translations,
value.language,
{
namespace: value.namespace,
overwrite: value.overwrite
}
);
res.json({
success: true,
results
});
} catch (error) {
logger.error('Failed to import translations:', error);
res.status(500).json({
success: false,
error: 'Failed to import translations'
});
}
});
/**
* GET /api/v1/translations/export
* Export translations
*/
router.get('/export', async (req, res) => {
try {
const { language, namespace = 'translation', format = 'json' } = req.query;
if (!language) {
return res.status(400).json({
success: false,
error: 'Language parameter is required'
});
}
const data = await translationService.exportTranslations(language, {
namespace,
format
});
const contentType = format === 'csv' ? 'text/csv' : 'application/json';
const filename = `translations_${language}_${namespace}.${format}`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(data);
} catch (error) {
logger.error('Failed to export translations:', error);
res.status(500).json({
success: false,
error: 'Failed to export translations'
});
}
});
/**
* POST /api/v1/translations/translate
* Auto-translate text
*/
router.post('/translate', async (req, res) => {
try {
const { text, sourceLang, targetLang } = req.body;
if (!text || !sourceLang || !targetLang) {
return res.status(400).json({
success: false,
error: 'text, sourceLang, and targetLang are required'
});
}
const translatedText = await translationService.autoTranslate(
text,
sourceLang,
targetLang
);
res.json({
success: true,
translation: {
original: text,
translated: translatedText,
sourceLang,
targetLang
}
});
} catch (error) {
logger.error('Failed to auto-translate:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to auto-translate'
});
}
});
/**
* GET /api/v1/translations/missing
* Get missing translations for a language
*/
router.get('/missing', async (req, res) => {
try {
const { language, namespace = 'translation' } = req.query;
if (!language) {
return res.status(400).json({
success: false,
error: 'Language parameter is required'
});
}
const missingKeys = await Translation.getMissingTranslations(
language,
namespace
);
res.json({
success: true,
language,
namespace,
missingKeys,
total: missingKeys.length
});
} catch (error) {
logger.error('Failed to get missing translations:', error);
res.status(500).json({
success: false,
error: 'Failed to get missing translations'
});
}
});
/**
* PUT /api/v1/translations/:id/verify
* Verify a translation
*/
router.put('/:id/verify', async (req, res) => {
try {
const translation = await Translation.findById(req.params.id);
if (!translation) {
return res.status(404).json({
success: false,
error: 'Translation not found'
});
}
await translation.verify(req.user?.id || 'system');
res.json({
success: true,
translation
});
} catch (error) {
logger.error('Failed to verify translation:', error);
res.status(500).json({
success: false,
error: 'Failed to verify translation'
});
}
});
/**
* DELETE /api/v1/translations/:id
* Delete a translation
*/
router.delete('/:id', async (req, res) => {
try {
const translation = await Translation.findById(req.params.id);
if (!translation) {
return res.status(404).json({
success: false,
error: 'Translation not found'
});
}
translation.isActive = false;
await translation.save();
res.json({
success: true,
message: 'Translation deleted successfully'
});
} catch (error) {
logger.error('Failed to delete translation:', error);
res.status(500).json({
success: false,
error: 'Failed to delete translation'
});
}
});
export default router;

View File

@@ -0,0 +1,228 @@
import { Language } from '../models/Language.js';
import acceptLanguageParser from 'accept-language-parser';
import { config } from '../config/index.js';
export class LocalizationService {
constructor() {
this.numberFormatters = new Map();
this.dateFormatters = new Map();
}
/**
* Detect user's preferred language
*/
async detectLanguage(req) {
// 1. Check explicit language parameter
if (req.query.lang) {
const lang = await Language.findOne({ code: req.query.lang, enabled: true });
if (lang) return lang.code;
}
// 2. Check cookie
if (req.cookies && req.cookies[config.detection.cookieName]) {
const lang = await Language.findOne({
code: req.cookies[config.detection.cookieName],
enabled: true
});
if (lang) return lang.code;
}
// 3. Check Accept-Language header
if (req.headers['accept-language']) {
const acceptedLanguages = acceptLanguageParser.parse(
req.headers['accept-language']
);
const enabledLanguages = await Language.find({ enabled: true });
const enabledCodes = enabledLanguages.map(l => l.code);
for (const acceptedLang of acceptedLanguages) {
// Try exact match
if (enabledCodes.includes(acceptedLang.code)) {
return acceptedLang.code;
}
// Try language without region
const langOnly = acceptedLang.code.split('-')[0];
if (enabledCodes.includes(langOnly)) {
return langOnly;
}
// Try to find any variant of the language
const variant = enabledCodes.find(code =>
code.startsWith(langOnly + '-')
);
if (variant) return variant;
}
}
// 4. Check user's location (if available)
if (req.headers['cf-ipcountry']) {
// Cloudflare country code
const countryLang = await this.getLanguageByCountry(
req.headers['cf-ipcountry']
);
if (countryLang) return countryLang;
}
// 5. Return default language
const defaultLang = await Language.getDefault();
return defaultLang ? defaultLang.code : config.i18n.defaultLanguage;
}
/**
* Get language by country code
*/
async getLanguageByCountry(countryCode) {
const countryLanguages = {
US: 'en', GB: 'en', CA: 'en', AU: 'en',
ES: 'es', MX: 'es', AR: 'es', CO: 'es',
FR: 'fr', BE: 'fr', CH: 'fr',
DE: 'de', AT: 'de',
CN: 'zh', TW: 'zh', HK: 'zh',
JP: 'ja',
RU: 'ru',
SA: 'ar', AE: 'ar', EG: 'ar',
BR: 'pt', PT: 'pt',
IT: 'it'
};
const langCode = countryLanguages[countryCode];
if (!langCode) return null;
const lang = await Language.findOne({ code: langCode, enabled: true });
return lang ? lang.code : null;
}
/**
* Format number according to locale
*/
formatNumber(number, language, options = {}) {
const cacheKey = `${language}:${JSON.stringify(options)}`;
if (!this.numberFormatters.has(cacheKey)) {
this.numberFormatters.set(
cacheKey,
new Intl.NumberFormat(this.getLocaleCode(language), options)
);
}
return this.numberFormatters.get(cacheKey).format(number);
}
/**
* Format currency according to locale
*/
formatCurrency(amount, language, currency = 'USD') {
return this.formatNumber(amount, language, {
style: 'currency',
currency: currency
});
}
/**
* Format date according to locale
*/
formatDate(date, language, options = {}) {
const cacheKey = `${language}:${JSON.stringify(options)}`;
if (!this.dateFormatters.has(cacheKey)) {
this.dateFormatters.set(
cacheKey,
new Intl.DateTimeFormat(this.getLocaleCode(language), options)
);
}
return this.dateFormatters.get(cacheKey).format(new Date(date));
}
/**
* Format relative time
*/
formatRelativeTime(date, language) {
const rtf = new Intl.RelativeTimeFormat(this.getLocaleCode(language), {
numeric: 'auto'
});
const now = new Date();
const target = new Date(date);
const diffInSeconds = Math.floor((target - now) / 1000);
const units = [
{ unit: 'year', seconds: 31536000 },
{ unit: 'month', seconds: 2592000 },
{ unit: 'week', seconds: 604800 },
{ unit: 'day', seconds: 86400 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
{ unit: 'second', seconds: 1 }
];
for (const { unit, seconds } of units) {
const diff = Math.floor(diffInSeconds / seconds);
if (Math.abs(diff) >= 1) {
return rtf.format(diff, unit);
}
}
return rtf.format(0, 'second');
}
/**
* Get locale code for Intl APIs
*/
getLocaleCode(language) {
const localeMap = {
en: 'en-US',
es: 'es-ES',
fr: 'fr-FR',
de: 'de-DE',
zh: 'zh-CN',
ja: 'ja-JP',
ru: 'ru-RU',
ar: 'ar-SA',
pt: 'pt-BR',
it: 'it-IT'
};
return localeMap[language] || language;
}
/**
* Get text direction for language
*/
async getTextDirection(language) {
const lang = await Language.findOne({ code: language });
return lang ? lang.direction : 'ltr';
}
/**
* Get all enabled languages
*/
async getEnabledLanguages() {
const languages = await Language.getEnabled();
return languages.map(lang => ({
code: lang.code,
name: lang.name,
nativeName: lang.nativeName,
direction: lang.direction,
flag: lang.metadata?.flag,
completion: lang.getCompletionPercentage()
}));
}
/**
* Sort text according to locale
*/
sortByLocale(items, language, key) {
const collator = new Intl.Collator(this.getLocaleCode(language));
return items.sort((a, b) => {
const aValue = key ? a[key] : a;
const bValue = key ? b[key] : b;
return collator.compare(aValue, bValue);
});
}
}
export const localizationService = new LocalizationService();

View File

@@ -0,0 +1,377 @@
import { Translation } from '../models/Translation.js';
import { Language } from '../models/Language.js';
import { config } from '../config/index.js';
import axios from 'axios';
import { logger } from '../utils/logger.js';
export class TranslationService {
constructor() {
this.cache = new Map();
this.providers = {
google: this.googleTranslate.bind(this),
deepl: this.deeplTranslate.bind(this),
openai: this.openaiTranslate.bind(this)
};
}
/**
* Get translation for a key
*/
async getTranslation(key, language, options = {}) {
const {
namespace = 'translation',
context = null,
defaultValue = key,
variables = {},
count = null
} = options;
// Check cache first
const cacheKey = `${language}:${namespace}:${key}:${context || 'default'}`;
if (this.cache.has(cacheKey)) {
return this.interpolate(this.cache.get(cacheKey), variables);
}
// Find translation
let translation = await Translation.findOne({
key,
language,
namespace,
context: context || { $exists: false },
isActive: true
});
// Handle pluralization
if (count !== null && translation) {
const pluralForm = this.getPluralForm(language, count);
const pluralTranslation = await Translation.findOne({
key,
language,
namespace,
context,
pluralForm,
isActive: true
});
if (pluralTranslation) {
translation = pluralTranslation;
}
}
// Fallback to default language
if (!translation) {
const defaultLang = await Language.getDefault();
if (defaultLang && defaultLang.code !== language) {
translation = await Translation.findOne({
key,
language: defaultLang.code,
namespace,
context: context || { $exists: false },
isActive: true
});
}
}
// Auto-translate if enabled and not found
if (!translation && config.translation.autoTranslate) {
const sourceTranslation = await Translation.findOne({
key,
language: config.i18n.defaultLanguage,
namespace,
isActive: true
});
if (sourceTranslation) {
try {
const translatedValue = await this.autoTranslate(
sourceTranslation.value,
config.i18n.defaultLanguage,
language
);
translation = await Translation.create({
key,
namespace,
language,
value: translatedValue,
context,
metadata: {
source: 'auto',
translatedAt: new Date()
}
});
} catch (error) {
logger.error('Auto-translation failed:', error);
}
}
}
const value = translation ? translation.value : defaultValue;
// Cache the result
if (config.translation.cacheTranslations) {
this.cache.set(cacheKey, value);
}
// Update usage statistics
if (translation) {
translation.incrementUsage(options.usageContext);
}
return this.interpolate(value, variables);
}
/**
* Get multiple translations
*/
async getTranslations(keys, language, options = {}) {
const { namespace = 'translation' } = options;
const translations = await Translation.getTranslations(keys, language, namespace);
const result = {};
for (const key of keys) {
result[key] = translations[key] || key;
}
return result;
}
/**
* Set translation
*/
async setTranslation(key, language, value, options = {}) {
const {
namespace = 'translation',
context = null,
metadata = {}
} = options;
const translation = await Translation.findOneAndUpdate(
{ key, language, namespace, context },
{
value,
metadata: {
...metadata,
source: metadata.source || 'manual',
translatedAt: new Date()
},
isActive: true
},
{ upsert: true, new: true }
);
// Clear cache
const cacheKey = `${language}:${namespace}:${key}:${context || 'default'}`;
this.cache.delete(cacheKey);
return translation;
}
/**
* Bulk import translations
*/
async importTranslations(translations, language, options = {}) {
const { namespace = 'translation', overwrite = false } = options;
const results = { imported: 0, skipped: 0, errors: [] };
for (const [key, value] of Object.entries(translations)) {
try {
const exists = await Translation.findOne({ key, language, namespace });
if (exists && !overwrite) {
results.skipped++;
continue;
}
await this.setTranslation(key, language, value, {
namespace,
metadata: { source: 'import' }
});
results.imported++;
} catch (error) {
results.errors.push({ key, error: error.message });
}
}
return results;
}
/**
* Export translations
*/
async exportTranslations(language, options = {}) {
const { namespace = 'translation', format = 'json' } = options;
const translations = await Translation.find({
language,
namespace,
isActive: true
});
const data = translations.reduce((acc, trans) => {
if (trans.context) {
acc[`${trans.key}_${trans.context}`] = trans.value;
} else {
acc[trans.key] = trans.value;
}
return acc;
}, {});
if (format === 'json') {
return JSON.stringify(data, null, 2);
} else if (format === 'csv') {
const csv = ['key,value'];
for (const [key, value] of Object.entries(data)) {
csv.push(`"${key}","${value.replace(/"/g, '""')}"`);
}
return csv.join('\n');
}
return data;
}
/**
* Auto-translate using configured provider
*/
async autoTranslate(text, sourceLang, targetLang) {
const provider = this.providers[config.translation.translationProvider];
if (!provider) {
throw new Error(`Translation provider ${config.translation.translationProvider} not supported`);
}
return provider(text, sourceLang, targetLang);
}
/**
* Google Translate
*/
async googleTranslate(text, sourceLang, targetLang) {
if (!config.translation.googleApiKey) {
throw new Error('Google Translate API key not configured');
}
const response = await axios.post(
`https://translation.googleapis.com/language/translate/v2`,
{
q: text,
source: sourceLang,
target: targetLang,
format: 'text'
},
{
params: { key: config.translation.googleApiKey }
}
);
return response.data.data.translations[0].translatedText;
}
/**
* DeepL Translate
*/
async deeplTranslate(text, sourceLang, targetLang) {
if (!config.translation.deeplApiKey) {
throw new Error('DeepL API key not configured');
}
const response = await axios.post(
'https://api-free.deepl.com/v2/translate',
new URLSearchParams({
auth_key: config.translation.deeplApiKey,
text: text,
source_lang: sourceLang.toUpperCase(),
target_lang: targetLang.toUpperCase()
}),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
}
);
return response.data.translations[0].text;
}
/**
* OpenAI Translate
*/
async openaiTranslate(text, sourceLang, targetLang) {
if (!config.translation.openaiApiKey) {
throw new Error('OpenAI API key not configured');
}
const response = await axios.post(
'https://api.openai.com/v1/chat/completions',
{
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: `You are a professional translator. Translate the following text from ${sourceLang} to ${targetLang}. Only return the translation, nothing else.`
},
{
role: 'user',
content: text
}
],
temperature: 0.3
},
{
headers: {
'Authorization': `Bearer ${config.translation.openaiApiKey}`,
'Content-Type': 'application/json'
}
}
);
return response.data.choices[0].message.content.trim();
}
/**
* Interpolate variables in translation
*/
interpolate(text, variables = {}) {
return text.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return variables[key] !== undefined ? variables[key] : match;
});
}
/**
* Get plural form for a language and count
*/
getPluralForm(language, count) {
// Simplified plural rules - in production, use a proper library
const rules = {
en: (n) => n === 1 ? 'one' : 'other',
es: (n) => n === 1 ? 'one' : 'other',
fr: (n) => n === 0 || n === 1 ? 'one' : 'other',
de: (n) => n === 1 ? 'one' : 'other',
zh: () => 'other',
ja: () => 'other',
ru: (n) => {
if (n % 10 === 1 && n % 100 !== 11) return 'one';
if ([2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100)) return 'few';
return 'many';
},
ar: (n) => {
if (n === 0) return 'zero';
if (n === 1) return 'one';
if (n === 2) return 'two';
if (n % 100 >= 3 && n % 100 <= 10) return 'few';
if (n % 100 >= 11) return 'many';
return 'other';
}
};
const rule = rules[language] || rules.en;
return rule(count);
}
/**
* Clear translation cache
*/
clearCache() {
this.cache.clear();
}
}
export const translationService = new TranslationService();

View File

@@ -0,0 +1,33 @@
import winston from 'winston';
import { config } from '../config/index.js';
const logFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
);
export const logger = winston.createLogger({
level: config.logging.level,
format: logFormat,
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
]
});
// Add file transport in production
if (process.env.NODE_ENV === 'production') {
logger.add(new winston.transports.File({
filename: 'logs/error.log',
level: 'error'
}));
logger.add(new winston.transports.File({
filename: 'logs/combined.log'
}));
}