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:
2026-05-01 12:04:49 +08:00
parent 91dd834413
commit 27100fbd22
36 changed files with 5055 additions and 866 deletions

View File

@@ -7,6 +7,7 @@ const scrypt = promisify(scryptCallback);
const passwordKeyLength = 64;
const verificationTokenHours = 24;
const sessionDays = 30;
const defaultLocale = 'en';
type DbClient = PoolClient;
@@ -23,6 +24,22 @@ type LoginUserRow = UserRow & {
password_hash: string;
};
type AuthMessageKey =
| 'emailRequired'
| 'invalidEmail'
| 'displayNameRequired'
| 'displayNameLength'
| 'passwordLength'
| 'invalidToken'
| 'emailAlreadyRegistered'
| 'checkVerificationEmail'
| 'emailVerified'
| 'invalidCredentials'
| 'verifyEmailFirst'
| 'emailSubject'
| 'emailHtml'
| 'emailText';
export type AuthUser = {
id: number;
email: string;
@@ -36,43 +53,87 @@ function statusError(message: string, statusCode: number): StatusError {
return error;
}
function cleanEmail(value: unknown): string {
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
const messages: Record<string, Record<AuthMessageKey, string>> = {
en: {
emailRequired: 'Email is required',
invalidEmail: 'Email format is invalid',
displayNameRequired: 'Display name is required',
displayNameLength: 'Display name must be 1 to 40 characters',
passwordLength: 'Password must be at least 8 characters',
invalidToken: 'The verification link is invalid or expired',
emailAlreadyRegistered: 'This email is already registered',
checkVerificationEmail: 'Please check your verification email',
emailVerified: 'Email verified',
invalidCredentials: 'Email or password is incorrect',
verifyEmailFirst: 'Please complete email verification first',
emailSubject: 'Verify your Pokopia Wiki email',
emailHtml:
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
},
'zh-CN': {
emailRequired: '请输入邮箱',
invalidEmail: '邮箱格式不正确',
displayNameRequired: '请输入显示名',
displayNameLength: '显示名长度需为 1 到 40 个字符',
passwordLength: '密码至少需要 8 个字符',
invalidToken: '验证链接无效或已过期',
emailAlreadyRegistered: '该邮箱已注册',
checkVerificationEmail: '请查收验证邮件',
emailVerified: '邮箱已验证',
invalidCredentials: '邮箱或密码不正确',
verifyEmailFirst: '请先完成邮箱验证',
emailSubject: '验证你的 Pokopia Wiki 邮箱',
emailHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
}
};
let message = messages[locale]?.[key] ?? messages[defaultLocale][key];
for (const [paramKey, paramValue] of Object.entries(params)) {
message = message.replaceAll(`{${paramKey}}`, String(paramValue));
}
return message;
}
function cleanEmail(value: unknown, locale: string): string {
if (typeof value !== 'string') {
throw statusError('请输入邮箱', 400);
throw statusError(authMessage(locale, 'emailRequired'), 400);
}
const email = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw statusError('邮箱格式不正确', 400);
throw statusError(authMessage(locale, 'invalidEmail'), 400);
}
return email;
}
function cleanDisplayName(value: unknown): string {
function cleanDisplayName(value: unknown, locale: string): string {
if (typeof value !== 'string') {
throw statusError('请输入显示名', 400);
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
}
const displayName = value.trim();
if (displayName.length < 1 || displayName.length > 40) {
throw statusError('显示名长度需为 1 到 40 个字符', 400);
throw statusError(authMessage(locale, 'displayNameLength'), 400);
}
return displayName;
}
function cleanPassword(value: unknown): string {
function cleanPassword(value: unknown, locale: string): string {
if (typeof value !== 'string' || value.length < 8) {
throw statusError('密码至少需要 8 个字符', 400);
throw statusError(authMessage(locale, 'passwordLength'), 400);
}
return value;
}
function cleanToken(value: unknown): string {
function cleanToken(value: unknown, locale: string): string {
if (typeof value !== 'string' || value.trim().length < 32) {
throw statusError('验证链接无效或已过期', 400);
throw statusError(authMessage(locale, 'invalidToken'), 400);
}
return value.trim();
@@ -155,7 +216,7 @@ function buildVerificationUrl(token: string): string {
return url.toString();
}
async function sendVerificationEmail(email: string, token: string): Promise<void> {
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
const { apiKey, from } = getEmailConfig();
const verificationUrl = buildVerificationUrl(token);
const response = await fetch('https://api.resend.com/emails', {
@@ -167,9 +228,9 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
body: JSON.stringify({
from,
to: [email],
subject: '验证你的 Pokopia Wiki 邮箱',
html: `<p>请点击下面的链接完成邮箱验证:</p><p><a href="${verificationUrl}">验证邮箱</a></p><p>链接将在 ${verificationTokenHours} 小时后失效。</p>`,
text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。`
subject: authMessage(locale, 'emailSubject'),
html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
})
});
@@ -179,10 +240,10 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
}
}
export async function registerUser(payload: Record<string, unknown>) {
const email = cleanEmail(payload.email);
const displayName = cleanDisplayName(payload.displayName);
const password = cleanPassword(payload.password);
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email, locale);
const displayName = cleanDisplayName(payload.displayName, locale);
const password = cleanPassword(payload.password, locale);
const passwordHash = await hashPassword(password);
const verificationToken = createPlainToken();
const verificationTokenHash = hashToken(verificationToken);
@@ -195,7 +256,7 @@ export async function registerUser(payload: Record<string, unknown>) {
);
if (existingUser?.email_verified_at) {
throw statusError('该邮箱已注册', 409);
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
}
const user = existingUser
@@ -233,12 +294,12 @@ export async function registerUser(payload: Record<string, unknown>) {
);
});
await sendVerificationEmail(email, verificationToken);
return { message: '请查收验证邮件' };
await sendVerificationEmail(email, verificationToken, locale);
return { message: authMessage(locale, 'checkVerificationEmail') };
}
export async function verifyEmail(payload: Record<string, unknown>) {
const token = cleanToken(payload.token);
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
const token = cleanToken(payload.token, locale);
const tokenHash = hashToken(token);
return withTransaction(async (client) => {
@@ -256,7 +317,7 @@ export async function verifyEmail(payload: Record<string, unknown>) {
);
if (!tokenRow) {
throw statusError('验证链接无效或已过期', 400);
throw statusError(authMessage(locale, 'invalidToken'), 400);
}
const user = await clientQueryOne<UserRow>(
@@ -271,31 +332,31 @@ export async function verifyEmail(payload: Record<string, unknown>) {
);
if (!user) {
throw statusError('验证链接无效或已过期', 400);
throw statusError(authMessage(locale, 'invalidToken'), 400);
}
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
user.id
]);
return { message: '邮箱已验证', user: toPublicUser(user) };
return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
});
}
export async function loginUser(payload: Record<string, unknown>) {
const email = cleanEmail(payload.email);
const password = cleanPassword(payload.password);
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email, locale);
const password = cleanPassword(payload.password, locale);
const user = await queryOne<LoginUserRow>(
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
[email]
);
if (!user || !(await verifyPassword(password, user.password_hash))) {
throw statusError('邮箱或密码不正确', 401);
throw statusError(authMessage(locale, 'invalidCredentials'), 401);
}
if (!user.email_verified_at) {
throw statusError('请先完成邮箱验证', 403);
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
}
const sessionToken = createPlainToken();