Skip to content

Commit 4d9ae3f

Browse files
committed
refactor: add i18n-style function with interpolation and introduce AppErrorI18n class
1 parent a5146ec commit 4d9ae3f

File tree

14 files changed

+140
-38
lines changed

14 files changed

+140
-38
lines changed

README.ko.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ Microsoft 공식 CodePush 서비스는 아시아 지역에서 속도가 느린
99

1010
## Requirement
1111

12-
- Node.js v24.6.0
12+
- Node.js v24.6.0 by package.json > engines
1313
- npm install -g install @shm-open/code-push-cli
1414
- data, storage 이름으로 디렉토리 생성후 서버 구동
15-
- 앱테스터 semver 포맷 지원을 위해 parseVersion 유틸함수 일부 수정 → cleanVersion 선행
16-
> e.g., '2.10.2-stg-01' → '2.10.2'
15+
16+
## Key Changes
17+
18+
- `AppErrorI18n` 클래스를 추가하여 에러 메시지를 포함한 다양한 텍스트에 대한 다국어(i18n) 지원을 제공함.
19+
- 요청(Request) 컨텍스트에 `i18n` 유틸리티와 `lang` 필드를 추가하여 라우터 및 서비스 계층에서 일관된 번역 기능을 활용할 수 있도록 개선함.
20+
- JSON 파일을 import하는 경우 TypeScript가 `src` 외부 디렉터리를 소스 경로로 포함하게 되어 `bin/locales``bin/src` 디렉터리가 생성되는 문제가 발생함.
21+
- 기존 `bin` 디렉터리의 구조를 변경하지 않기 위해, i18n 관련 리소스(`locales` 디렉터리)를 `src/locales`로 이동하여 빌드 결과물을 안정적으로 유지함.
22+
- App Tester의 semver 포맷 처리를 개선하기 위해 `parseVersion` 유틸 함수를 수정하고, `cleanVersion` 과정을 선행하도록 구조를 보완함.
23+
- e.g., `'2.10.2-stg-01'` → `'2.10.2'`
1724

1825
## 이 포크(Fork)에 대하여
1926

src/app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { logger } from 'kv-logger';
88
import { AppError, NotFound } from './core/app-error';
99
import { config } from './core/config';
1010
import { i18n } from './core/i18n';
11-
import { Req, Res, withLogger } from './core/middleware';
11+
import { i18nMiddleware, Req, Res, withLogger } from './core/middleware';
1212
import { accessKeysRouter } from './routes/accessKeys';
1313
import { accountRouter } from './routes/account';
1414
import { appsRouter } from './routes/apps';
@@ -48,6 +48,9 @@ app.all('*', (req, res, next) => {
4848
next();
4949
});
5050

51+
// i18n 미들웨어 (req.lang, req.t 세팅)
52+
app.use(i18nMiddleware);
53+
5154
logger.debug(`config common.storageType value: ${config.common.storageType}`);
5255

5356
// config local storage

src/core/app-error.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,23 @@ export class AppError extends Error {
88
public status = 200;
99
}
1010

11+
/** i18n 에러 메시지 번역 포함 */
12+
export class AppErrorI18n extends Error {
13+
public readonly messageKey: string;
14+
public readonly messageVars?: Record<string, any>;
15+
16+
constructor(messageKey: string, messageVars?: Record<string, any>) {
17+
// Error.message에는 일단 key만 넣어둠 (실제 표현은 나중에 req.t로 처리)
18+
super(messageKey);
19+
20+
this.messageKey = messageKey;
21+
this.messageVars = messageVars;
22+
23+
Object.setPrototypeOf(this, new.target.prototype); // Error 상속시 필수 처리
24+
this.name = 'AppErrorI18n';
25+
}
26+
}
27+
1128
export class NotFound extends AppError {
1229
constructor(message?: string | Error) {
1330
super(message || 'Not Found');

src/core/i18n.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,46 @@
11
import path from 'path';
22
import { I18n } from 'i18n';
33

4+
import en from '../locales/en.json';
5+
import ko from '../locales/ko.json';
6+
import zh from '../locales/zh.json';
7+
import type { LocaleI18n } from './middleware';
8+
9+
const resources: Record<LocaleI18n, Record<string, string>> = {
10+
en,
11+
ko,
12+
zh,
13+
};
14+
415
export const i18n = new I18n();
516

617
i18n.configure({
7-
directory: path.join(__dirname, '../../locales'),
18+
directory: path.join(__dirname, '../locales'),
819
defaultLocale: 'en',
920
});
21+
22+
/**
23+
* @description 모던 i18next 라이브러리의 t함수와 유사함 동작 보장
24+
* - 이 프로젝트의 i18n 의 번역함수 사용법 예시
25+
* - e.g., req.__("greeting") OR res.__("greeting")
26+
* - 위 방법 외에 t("greeting") 호출로도 동일한 스트링 리턴 보장
27+
*/
28+
export function t(locale: LocaleI18n, key: string, vars?: Record<string, any>): string {
29+
const dict = resources[locale] ?? resources.zh;
30+
31+
// 키에 해당하는 문장 (없으면 key 자체 반환)
32+
const template = dict[key] ?? key;
33+
34+
// 변수 치환 없음 -> 그대로 반환
35+
if (!vars) {
36+
return template;
37+
}
38+
39+
// Interpolation 처리: {{varName}} -> vars[varName]
40+
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, varName) => {
41+
if (vars[varName] == null) {
42+
return ''; // 없는 변수를 치환하면 빈문자열 (i18next 기본 동작과 유사)
43+
}
44+
return String(vars[varName]);
45+
});
46+
}

src/core/middleware.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ import { UserTokens } from '../models/user_tokens';
99
import { Users, UsersInterface } from '../models/users';
1010
import { AppError, Unauthorized } from './app-error';
1111
import { config } from './config';
12+
import { t } from './i18n';
1213
import { parseToken, md5 } from './utils/security';
1314

15+
export type LocaleI18n = 'en' | 'ko' | 'zh';
16+
1417
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1518
export interface Req<P = Record<string, string>, B = any, Q = Record<string, string | string[]>>
1619
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1720
extends Request<P, any, B, Partial<Q>> {
1821
users: UsersInterface;
1922
logger: Logger;
23+
lang?: LocaleI18n;
24+
t?: (key: string, vars?: Record<string, any>) => string;
2025
}
2126

2227
// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-explicit-any
@@ -141,3 +146,21 @@ export function checkToken(req: Req, res: Res, next: NextFunction) {
141146
}
142147
});
143148
}
149+
150+
function getLocaleFromReq(req: Req): LocaleI18n {
151+
const langHeader = (req.headers['x-lang'] || req.headers['accept-language'] || '').toString();
152+
153+
if (langHeader.startsWith('ko')) {
154+
return 'ko';
155+
}
156+
return 'en';
157+
}
158+
159+
export function i18nMiddleware(req: Req, res: Res, next: NextFunction) {
160+
const locale = getLocaleFromReq(req);
161+
162+
req.lang = locale;
163+
req.t = (key: string, vars?: Record<string, any>) => t(locale, key, vars);
164+
165+
next();
166+
}

src/core/services/account-manager.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import validator from 'validator';
55
import { findCollaboratorsByAppNameAndUid } from '../../models/collaborators';
66
import { UserTokens } from '../../models/user_tokens';
77
import { Users } from '../../models/users';
8-
import { AppError } from '../app-error';
8+
import { AppError, AppErrorI18n } from '../app-error';
99
import { config } from '../config';
1010
import { redisClient } from '../utils/connections';
1111
import { passwordVerifySync, randToken, md5, passwordHashSync } from '../utils/security';
@@ -98,10 +98,10 @@ class AccountManager {
9898

9999
login(account: string, password: string) {
100100
if (_.isEmpty(account)) {
101-
return Promise.reject(new AppError('이메일 주소를 입력해주세요.')); // 请您输入邮箱地址
101+
return Promise.reject(new AppErrorI18n('error.input_email_required'));
102102
}
103103
if (_.isEmpty(password)) {
104-
return Promise.reject(new AppError('비밀번호를 입력해주세요.')); // 请您输入密码
104+
return Promise.reject(new AppErrorI18n('error.input_password_required'));
105105
}
106106
let where = {};
107107
if (validator.isEmail(account)) {
@@ -113,7 +113,7 @@ class AccountManager {
113113
return Users.findOne({ where })
114114
.then((users) => {
115115
if (_.isEmpty(users)) {
116-
throw new AppError('이메일 또는 비밀번호가 올바르지 않습니다.'); // 您输入的邮箱或密码有误
116+
throw new AppErrorI18n('error.invalid_credentials');
117117
}
118118
return users;
119119
})
@@ -122,9 +122,7 @@ class AccountManager {
122122
const loginKey = `${LOGIN_LIMIT_PRE}${users.id}`;
123123
return redisClient.get(loginKey).then((loginErrorTimes) => {
124124
if (Number(loginErrorTimes) > tryLoginTimes) {
125-
throw new AppError(
126-
`비밀번호 오류 횟수가 제한을 초과하여 계정이 잠겼습니다.`,
127-
); // 您输入密码错误次数超过限制,帐户已经锁定
125+
throw new AppErrorI18n('error.password_retry_limit_exceeded');
128126
}
129127
return users;
130128
});
@@ -144,7 +142,7 @@ class AccountManager {
144142
redisClient.incr(loginKey);
145143
});
146144
}
147-
throw new AppError('이메일 또는 비밀번호가 올바르지 않습니다.'); // 您输入的邮箱或密码有误
145+
throw new AppErrorI18n('error.invalid_credentials');
148146
} else {
149147
return users;
150148
}
@@ -153,16 +151,16 @@ class AccountManager {
153151

154152
sendRegisterCode(email: string) {
155153
if (_.isEmpty(email)) {
156-
return Promise.reject(new AppError('请您输入邮箱地址'));
154+
return Promise.reject(new AppErrorI18n('error.input_email_required'));
157155
}
158156
return Users.findOne({ where: { email } })
159157
.then((u) => {
160158
if (u) {
161-
throw new AppError(`"${email}" 已经注册过,请更换邮箱注册`);
159+
throw new AppErrorI18n('error.email_already_registered', { email });
162160
}
163161
})
164162
.then(() => {
165-
// 将token临时存储到redis
163+
// Store the token temporarily in Redis
166164
const token = randToken(40);
167165
return redisClient
168166
.setEx(`${REGISTER_CODE}${md5(email)}`, EXPIRED, token)
@@ -171,7 +169,7 @@ class AccountManager {
171169
});
172170
})
173171
.then((token) => {
174-
// 将token发送到用户邮箱
172+
// Send the token to user's email
175173
return emailManager.sendRegisterCodeMail(email, token);
176174
});
177175
}
@@ -180,22 +178,22 @@ class AccountManager {
180178
return Users.findOne({ where: { email } })
181179
.then((u) => {
182180
if (u) {
183-
throw new AppError(`"${email}" 已经注册过,请更换邮箱注册`);
181+
throw new AppErrorI18n('error.email_already_registered', { email });
184182
}
185183
})
186184
.then(() => {
187185
const registerKey = `${REGISTER_CODE}${md5(email)}`;
188186
return redisClient.get(registerKey).then((storageToken) => {
189187
if (_.isEmpty(storageToken)) {
190-
throw new AppError(`验证码已经失效,请您重新获取`);
188+
throw new AppErrorI18n('error.verify_code_expired');
191189
}
192190
if (!_.eq(token, storageToken)) {
193191
redisClient.ttl(registerKey).then((ttl) => {
194192
if (ttl > 0) {
195193
redisClient.expire(registerKey, ttl - EXPIRED_SPEED);
196194
}
197195
});
198-
throw new AppError(`您输入的验证码不正确,请重新输入`);
196+
throw new AppErrorI18n('error.verify_code_invalid');
199197
}
200198
return storageToken;
201199
});
@@ -206,7 +204,7 @@ class AccountManager {
206204
return Users.findOne({ where: { email } })
207205
.then((u) => {
208206
if (u) {
209-
throw new AppError(`"${email}" 已经注册过,请更换邮箱注册`);
207+
throw new AppErrorI18n('error.email_already_registered', { email });
210208
}
211209
})
212210
.then(() => {
@@ -221,19 +219,19 @@ class AccountManager {
221219

222220
changePassword(uid: number, oldPassword: string, newPassword: string) {
223221
if (!_.isString(newPassword) || newPassword.length < 6) {
224-
return Promise.reject(new AppError('请您输入6~20位长度的新密码'));
222+
return Promise.reject(new AppErrorI18n('error.new_password_length'));
225223
}
226224
return Users.findOne({ where: { id: uid } })
227225
.then((u) => {
228226
if (!u) {
229-
throw new AppError(`未找到用户信息`);
227+
throw new AppErrorI18n('error.user_not_found');
230228
}
231229
return u;
232230
})
233231
.then((u) => {
234232
const isEq = passwordVerifySync(oldPassword, u.get('password'));
235233
if (!isEq) {
236-
throw new AppError(`您输入的旧密码不正确,请重新输入`);
234+
throw new AppErrorI18n('error.old_password_incorrect');
237235
}
238236
u.set('password', passwordHashSync(newPassword));
239237
u.set('ack_code', randToken(5));

locales/en.json renamed to src/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
"change success": "change success",
1515
"Logout": "Logout",
1616
"Register": "Register",
17+
"server.name": "CodePush Server",
1718
"error.input_email_required": "Please enter your email address.",
1819
"error.input_password_required": "Please enter your password.",
1920
"error.invalid_credentials": "The email or password you entered is incorrect.",
2021
"error.password_retry_limit_exceeded": "Your account has been locked due to too many failed login attempts.",
21-
"error.email_already_registered": "This email is already registered. Please use a different email address.",
22+
"error.email_already_registered": "{{email}} is already registered. Please use a different email address.",
2223
"error.verify_code_expired": "The verification code has expired. Please request a new one.",
2324
"error.verify_code_invalid": "The verification code is incorrect. Please try again.",
2425
"error.new_password_length": "Please enter a new password between 6 and 20 characters.",

locales/ko.json renamed to src/locales/ko.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
"change success": "변경되었습니다",
1515
"Logout": "로그아웃",
1616
"Register": "회원가입",
17+
"server.name": "코드푸시 서버",
1718
"error.input_email_required": "이메일 주소를 입력해주세요.",
1819
"error.input_password_required": "비밀번호를 입력해주세요.",
1920
"error.invalid_credentials": "이메일 또는 비밀번호가 올바르지 않습니다.",
2021
"error.password_retry_limit_exceeded": "비밀번호 오류 횟수가 제한을 초과하여 계정이 잠겼습니다.",
21-
"error.email_already_registered": "이미 등록된 이메일입니다. 다른 이메일을 사용해주세요.",
22+
"error.email_already_registered": "{{email}}은 이미 등록된 이메일입니다. 다른 이메일을 사용해주세요.",
2223
"error.verify_code_expired": "인증 코드가 만료되었습니다. 다시 발급받아주세요.",
2324
"error.verify_code_invalid": "인증 코드가 올바르지 않습니다. 다시 입력해주세요.",
2425
"error.new_password_length": "새 비밀번호는 6~20자 사이로 입력해주세요.",

locales/zh.json renamed to src/locales/zh.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414
"change success": "修改成功",
1515
"Logout": "登出",
1616
"Register": "注册",
17+
"server.name": "CodePush 服务器",
1718
"error.input_email_required": "请您输入邮箱地址",
1819
"error.input_password_required": "请您输入密码",
1920
"error.invalid_credentials": "您输入的邮箱或密码有误",
2021
"error.password_retry_limit_exceeded": "您输入密码错误次数超过限制,帐户已经锁定",
21-
"error.email_already_registered": "该邮箱已经注册过,请更换邮箱注册",
22+
"error.email_already_registered": "{{email}}已经注册过,请更换邮箱注册",
2223
"error.verify_code_expired": "验证码已经失效,请您重新获取",
2324
"error.verify_code_invalid": "您输入的验证码不正确,请重新输入",
2425
"error.new_password_length": "请您输入6~20位长度的新密码",

src/routes/auth.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import express from 'express';
22
import jwt from 'jsonwebtoken';
33
import _ from 'lodash';
4-
import { AppError } from '../core/app-error';
4+
import { AppErrorI18n } from '../core/app-error';
55
import { config } from '../core/config';
66
import { Req } from '../core/middleware';
77
import { accountManager } from '../core/services/account-manager';
@@ -70,12 +70,21 @@ authRouter.post(
7070
res.send({ status: 'OK', results: { tokens: token } });
7171
})
7272
.catch((e) => {
73-
if (e instanceof AppError) {
73+
if (e instanceof AppErrorI18n) {
74+
const { messageKey, messageVars } = e;
75+
const message =
76+
typeof req.t === 'function' ? req.t(messageKey, messageVars) : messageKey; // fallback
77+
7478
logger.info('login failed', {
7579
account,
76-
error: e.message,
80+
error: message,
81+
messageKey,
82+
});
83+
res.send({
84+
status: 'ERROR',
85+
message,
86+
code: messageKey,
7787
});
78-
res.send({ status: 'ERROR', message: e.message });
7988
} else {
8089
next(e);
8190
}

0 commit comments

Comments
 (0)