feat(i18n): add full-stack internationalization support
Add languages and entity_translations tables to database schema Implement localized queries and translation management in backend Integrate frontend i18n and add translation UI components
This commit is contained in:
@@ -4,16 +4,19 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
||||
import { initializeDatabase, pool } from './db.ts';
|
||||
import {
|
||||
cleanLocale,
|
||||
createConfig,
|
||||
createDailyChecklistItem,
|
||||
createHabitat,
|
||||
createItem,
|
||||
createLanguage,
|
||||
createPokemon,
|
||||
createRecipe,
|
||||
deleteConfig,
|
||||
deleteDailyChecklistItem,
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
getHabitat,
|
||||
@@ -26,13 +29,16 @@ import {
|
||||
listDailyChecklistItems,
|
||||
listHabitats,
|
||||
listItems,
|
||||
listLanguages,
|
||||
listPokemon,
|
||||
listRecipes,
|
||||
reorderDailyChecklistItems,
|
||||
reorderLanguages,
|
||||
updateConfig,
|
||||
updateDailyChecklistItem,
|
||||
updateHabitat,
|
||||
updateItem,
|
||||
updateLanguage,
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
} from './queries.ts';
|
||||
@@ -42,24 +48,25 @@ const app = Fastify({
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
allowedHeaders: ['Authorization', 'Content-Type'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
origin: process.env.FRONTEND_ORIGIN ?? true
|
||||
});
|
||||
|
||||
app.setErrorHandler(async (error, _request, reply) => {
|
||||
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
|
||||
const locale = requestLocale(_request);
|
||||
|
||||
if (pgError.code === '23503') {
|
||||
return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' });
|
||||
return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') });
|
||||
}
|
||||
|
||||
if (pgError.code === '23505') {
|
||||
return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' });
|
||||
return reply.code(409).send({ message: serverMessage(locale, 'duplicate') });
|
||||
}
|
||||
|
||||
if (pgError.code === '23514') {
|
||||
return reply.code(400).send({ message: '字段值不合法' });
|
||||
return reply.code(400).send({ message: serverMessage(locale, 'invalidField') });
|
||||
}
|
||||
|
||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||
@@ -67,7 +74,7 @@ app.setErrorHandler(async (error, _request, reply) => {
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({ message: '服务器错误' });
|
||||
return reply.code(500).send({ message: serverMessage(locale, 'serverError') });
|
||||
});
|
||||
|
||||
app.get('/health', async () => ({ ok: true }));
|
||||
@@ -77,17 +84,48 @@ function getBearerToken(authorization: string | undefined): string | null {
|
||||
return scheme === 'Bearer' && token ? token : null;
|
||||
}
|
||||
|
||||
function requestLocale(request: FastifyRequest): string {
|
||||
const query = request.query as Record<string, string | string[] | undefined>;
|
||||
const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale;
|
||||
const headerLocale = request.headers['x-locale'];
|
||||
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||
}
|
||||
|
||||
function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string {
|
||||
const messages = {
|
||||
en: {
|
||||
foreignKey: 'Referenced data does not exist or the record is currently in use',
|
||||
duplicate: 'A record with the same name or ID already exists',
|
||||
invalidField: 'Field value is invalid',
|
||||
serverError: 'Server error',
|
||||
loginRequired: 'Please log in first',
|
||||
verifyEmailFirst: 'Please complete email verification first'
|
||||
},
|
||||
'zh-CN': {
|
||||
foreignKey: '引用的数据不存在,或当前记录正在被使用',
|
||||
duplicate: '同名或相同 ID 的记录已存在',
|
||||
invalidField: '字段值不合法',
|
||||
serverError: '服务器错误',
|
||||
loginRequired: '请先登录',
|
||||
verifyEmailFirst: '请先完成邮箱验证'
|
||||
}
|
||||
};
|
||||
|
||||
return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key];
|
||||
}
|
||||
|
||||
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
const locale = requestLocale(request);
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({ message: '请先登录' });
|
||||
reply.code(401).send({ message: serverMessage(locale, 'loginRequired') });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
reply.code(403).send({ message: '请先完成邮箱验证' });
|
||||
reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') });
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -95,19 +133,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
||||
}
|
||||
|
||||
app.post('/api/auth/register', async (request, reply) =>
|
||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>))
|
||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
||||
);
|
||||
|
||||
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>));
|
||||
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>, requestLocale(request)));
|
||||
|
||||
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>));
|
||||
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
|
||||
|
||||
app.get('/api/auth/me', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user) {
|
||||
return reply.code(401).send({ message: '请先登录' });
|
||||
return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') });
|
||||
}
|
||||
|
||||
return { user };
|
||||
@@ -122,15 +160,19 @@ app.post('/api/auth/logout', async (request, reply) => {
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/api/options', async () => getOptions());
|
||||
app.get('/api/languages', async () => listLanguages());
|
||||
|
||||
app.get('/api/daily-checklist', async () => listDailyChecklistItems());
|
||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||
|
||||
app.get('/api/pokemon', async (request) => listPokemon(request.query as Record<string, string | string[] | undefined>));
|
||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||
|
||||
app.get('/api/pokemon', async (request) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.get('/api/pokemon/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const pokemon = await getPokemon(Number(id));
|
||||
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
||||
|
||||
if (!pokemon) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -141,7 +183,9 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/pokemon', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||
return user
|
||||
? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
});
|
||||
|
||||
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||
@@ -150,7 +194,7 @@ app.put('/api/pokemon/:id', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
|
||||
if (!pokemon) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -169,11 +213,11 @@ app.delete('/api/pokemon/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/habitats', async () => listHabitats());
|
||||
app.get('/api/habitats', async (request) => listHabitats(requestLocale(request)));
|
||||
|
||||
app.get('/api/habitats/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const habitat = await getHabitat(Number(id));
|
||||
const habitat = await getHabitat(Number(id), requestLocale(request));
|
||||
|
||||
if (!habitat) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -184,7 +228,9 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/habitats', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||
return user
|
||||
? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
});
|
||||
|
||||
app.put('/api/habitats/:id', async (request, reply) => {
|
||||
@@ -193,7 +239,7 @@ app.put('/api/habitats/:id', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
|
||||
if (!habitat) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -212,11 +258,13 @@ app.delete('/api/habitats/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>));
|
||||
app.get('/api/items', async (request) =>
|
||||
listItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.get('/api/items/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const item = await getItem(Number(id));
|
||||
const item = await getItem(Number(id), requestLocale(request));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -227,7 +275,9 @@ app.get('/api/items/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/items', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||
return user
|
||||
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
});
|
||||
|
||||
app.put('/api/items/:id', async (request, reply) => {
|
||||
@@ -236,7 +286,7 @@ app.put('/api/items/:id', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -255,11 +305,13 @@ app.delete('/api/items/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/recipes', async (request) => listRecipes(request.query as Record<string, string | string[] | undefined>));
|
||||
app.get('/api/recipes', async (request) =>
|
||||
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
app.get('/api/recipes/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const recipe = await getRecipe(Number(id));
|
||||
const recipe = await getRecipe(Number(id), requestLocale(request));
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -270,7 +322,9 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/recipes', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||
return user
|
||||
? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
});
|
||||
|
||||
app.put('/api/recipes/:id', async (request, reply) => {
|
||||
@@ -279,7 +333,7 @@ app.put('/api/recipes/:id', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
@@ -300,12 +354,16 @@ app.delete('/api/recipes/:id', async (request, reply) => {
|
||||
|
||||
app.post('/api/admin/daily-checklist', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||
return user
|
||||
? reply
|
||||
.code(201)
|
||||
.send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||
: undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/daily-checklist/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id) : undefined;
|
||||
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
@@ -314,7 +372,12 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
const item = await updateDailyChecklistItem(Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const item = await updateDailyChecklistItem(
|
||||
Number(id),
|
||||
request.body as Record<string, unknown>,
|
||||
user.id,
|
||||
requestLocale(request)
|
||||
);
|
||||
return item ? item : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
@@ -328,6 +391,40 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/languages', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? listLanguages(true) : undefined;
|
||||
});
|
||||
|
||||
app.post('/api/admin/languages', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reply.code(201).send(await createLanguage(request.body as Record<string, unknown>)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/languages/order', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
return user ? reorderLanguages(request.body as Record<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/languages/:code', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { code } = request.params as { code: string };
|
||||
return updateLanguage(code, request.body as Record<string, unknown>);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/languages/:code', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { code } = request.params as { code: string };
|
||||
const deleted = await deleteLanguage(code);
|
||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
@@ -337,7 +434,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
return listConfig(type);
|
||||
return listConfig(type, requestLocale(request));
|
||||
});
|
||||
|
||||
app.post('/api/admin/config/:type', async (request, reply) => {
|
||||
@@ -349,7 +446,9 @@ app.post('/api/admin/config/:type', async (request, reply) => {
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
return reply.code(201).send(await createConfig(type, request.body as Record<string, unknown>, user.id));
|
||||
return reply
|
||||
.code(201)
|
||||
.send(await createConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request)));
|
||||
});
|
||||
|
||||
app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
@@ -361,7 +460,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
}
|
||||
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id);
|
||||
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||
return config ? config : reply.code(404).send({ message: 'Not found' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user