다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가

This commit is contained in:
kjs
2026-01-13 18:28:11 +09:00
parent 989b7e53a7
commit b576837f18
23 changed files with 2745 additions and 182 deletions

View File

@@ -4,6 +4,7 @@ import {
Language,
LangKey,
LangText,
LangCategory,
CreateLanguageRequest,
UpdateLanguageRequest,
CreateLangKeyRequest,
@@ -12,12 +13,428 @@ import {
GetLangKeysParams,
GetUserTextParams,
BatchTranslationRequest,
GenerateKeyRequest,
CreateOverrideKeyRequest,
ApiResponse,
} from "../types/multilang";
export class MultiLangService {
constructor() {}
// =====================================================
// 카테고리 관련 메서드
// =====================================================
/**
* 카테고리 목록 조회 (트리 구조)
*/
async getCategories(): Promise<LangCategory[]> {
try {
logger.info("카테고리 목록 조회 시작");
const categories = await query<{
category_id: number;
category_code: string;
category_name: string;
parent_id: number | null;
level: number;
key_prefix: string;
description: string | null;
sort_order: number;
is_active: string;
}>(
`SELECT category_id, category_code, category_name, parent_id,
level, key_prefix, description, sort_order, is_active
FROM multi_lang_category
WHERE is_active = 'Y'
ORDER BY level ASC, sort_order ASC, category_name ASC`
);
// 트리 구조로 변환
const categoryMap = new Map<number, LangCategory>();
const rootCategories: LangCategory[] = [];
// 모든 카테고리를 맵에 저장
categories.forEach((cat) => {
const category: LangCategory = {
categoryId: cat.category_id,
categoryCode: cat.category_code,
categoryName: cat.category_name,
parentId: cat.parent_id,
level: cat.level,
keyPrefix: cat.key_prefix,
description: cat.description || undefined,
sortOrder: cat.sort_order,
isActive: cat.is_active,
children: [],
};
categoryMap.set(cat.category_id, category);
});
// 부모-자식 관계 설정
categoryMap.forEach((category) => {
if (category.parentId && categoryMap.has(category.parentId)) {
const parent = categoryMap.get(category.parentId)!;
parent.children = parent.children || [];
parent.children.push(category);
} else if (!category.parentId) {
rootCategories.push(category);
}
});
logger.info(`카테고리 목록 조회 완료: ${categories.length}`);
return rootCategories;
} catch (error) {
logger.error("카테고리 목록 조회 중 오류 발생:", error);
throw new Error(
`카테고리 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 카테고리 단일 조회
*/
async getCategoryById(categoryId: number): Promise<LangCategory | null> {
try {
const category = await queryOne<{
category_id: number;
category_code: string;
category_name: string;
parent_id: number | null;
level: number;
key_prefix: string;
description: string | null;
sort_order: number;
is_active: string;
}>(
`SELECT category_id, category_code, category_name, parent_id,
level, key_prefix, description, sort_order, is_active
FROM multi_lang_category
WHERE category_id = $1`,
[categoryId]
);
if (!category) {
return null;
}
return {
categoryId: category.category_id,
categoryCode: category.category_code,
categoryName: category.category_name,
parentId: category.parent_id,
level: category.level,
keyPrefix: category.key_prefix,
description: category.description || undefined,
sortOrder: category.sort_order,
isActive: category.is_active,
};
} catch (error) {
logger.error("카테고리 조회 중 오류 발생:", error);
throw new Error(
`카테고리 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 카테고리 경로 조회 (부모 포함)
*/
async getCategoryPath(categoryId: number): Promise<LangCategory[]> {
try {
const categories = await query<{
category_id: number;
category_code: string;
category_name: string;
parent_id: number | null;
level: number;
key_prefix: string;
description: string | null;
sort_order: number;
is_active: string;
}>(
`WITH RECURSIVE category_path AS (
SELECT category_id, category_code, category_name, parent_id,
level, key_prefix, description, sort_order, is_active
FROM multi_lang_category
WHERE category_id = $1
UNION ALL
SELECT c.category_id, c.category_code, c.category_name, c.parent_id,
c.level, c.key_prefix, c.description, c.sort_order, c.is_active
FROM multi_lang_category c
INNER JOIN category_path cp ON c.category_id = cp.parent_id
)
SELECT * FROM category_path ORDER BY level ASC`,
[categoryId]
);
return categories.map((cat) => ({
categoryId: cat.category_id,
categoryCode: cat.category_code,
categoryName: cat.category_name,
parentId: cat.parent_id,
level: cat.level,
keyPrefix: cat.key_prefix,
description: cat.description || undefined,
sortOrder: cat.sort_order,
isActive: cat.is_active,
}));
} catch (error) {
logger.error("카테고리 경로 조회 중 오류 발생:", error);
throw new Error(
`카테고리 경로 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 키 자동 생성
*/
async generateKey(params: GenerateKeyRequest): Promise<number> {
try {
logger.info("키 자동 생성 시작", { params });
// 카테고리 경로 조회
const categoryPath = await this.getCategoryPath(params.categoryId);
if (categoryPath.length === 0) {
throw new Error("존재하지 않는 카테고리입니다");
}
// lang_key 자동 생성 (prefix.meaning 형식)
const prefixes = categoryPath.map((c) => c.keyPrefix);
const langKey = [...prefixes, params.keyMeaning].join(".");
// 중복 체크
const existingKey = await queryOne<{ key_id: number }>(
`SELECT key_id FROM multi_lang_key_master
WHERE company_code = $1 AND lang_key = $2`,
[params.companyCode, langKey]
);
if (existingKey) {
throw new Error(`이미 존재하는 키입니다: ${langKey}`);
}
// 트랜잭션으로 키와 텍스트 생성
let keyId: number = 0;
await transaction(async (client) => {
// 키 생성
const keyResult = await client.query(
`INSERT INTO multi_lang_key_master
(company_code, lang_key, category_id, key_meaning, usage_note, description, is_active, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $7)
RETURNING key_id`,
[
params.companyCode,
langKey,
params.categoryId,
params.keyMeaning,
params.usageNote || null,
params.usageNote || null,
params.createdBy || "system",
]
);
keyId = keyResult.rows[0].key_id;
// 텍스트 생성
for (const text of params.texts) {
await client.query(
`INSERT INTO multi_lang_text
(key_id, lang_code, lang_text, is_active, created_by, updated_by)
VALUES ($1, $2, $3, 'Y', $4, $4)`,
[keyId, text.langCode, text.langText, params.createdBy || "system"]
);
}
});
logger.info("키 자동 생성 완료", { keyId, langKey });
return keyId;
} catch (error) {
logger.error("키 자동 생성 중 오류 발생:", error);
throw new Error(
`키 자동 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 회사별 오버라이드 키 생성
*/
async createOverrideKey(params: CreateOverrideKeyRequest): Promise<number> {
try {
logger.info("오버라이드 키 생성 시작", { params });
// 원본 키 조회
const baseKey = await queryOne<{
key_id: number;
company_code: string;
lang_key: string;
category_id: number | null;
key_meaning: string | null;
}>(
`SELECT key_id, company_code, lang_key, category_id, key_meaning
FROM multi_lang_key_master WHERE key_id = $1`,
[params.baseKeyId]
);
if (!baseKey) {
throw new Error("원본 키를 찾을 수 없습니다");
}
// 공통 키(*)만 오버라이드 가능
if (baseKey.company_code !== "*") {
throw new Error("공통 키(*)만 오버라이드 할 수 있습니다");
}
// 이미 오버라이드 키가 있는지 확인
const existingOverride = await queryOne<{ key_id: number }>(
`SELECT key_id FROM multi_lang_key_master
WHERE company_code = $1 AND lang_key = $2`,
[params.companyCode, baseKey.lang_key]
);
if (existingOverride) {
throw new Error("이미 해당 회사의 오버라이드 키가 존재합니다");
}
let keyId: number = 0;
await transaction(async (client) => {
// 오버라이드 키 생성
const keyResult = await client.query(
`INSERT INTO multi_lang_key_master
(company_code, lang_key, category_id, key_meaning, base_key_id, is_active, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $6)
RETURNING key_id`,
[
params.companyCode,
baseKey.lang_key,
baseKey.category_id,
baseKey.key_meaning,
params.baseKeyId,
params.createdBy || "system",
]
);
keyId = keyResult.rows[0].key_id;
// 텍스트 생성
for (const text of params.texts) {
await client.query(
`INSERT INTO multi_lang_text
(key_id, lang_code, lang_text, is_active, created_by, updated_by)
VALUES ($1, $2, $3, 'Y', $4, $4)`,
[keyId, text.langCode, text.langText, params.createdBy || "system"]
);
}
});
logger.info("오버라이드 키 생성 완료", { keyId, langKey: baseKey.lang_key });
return keyId;
} catch (error) {
logger.error("오버라이드 키 생성 중 오류 발생:", error);
throw new Error(
`오버라이드 키 생성 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 회사의 오버라이드 키 목록 조회
*/
async getOverrideKeys(companyCode: string): Promise<LangKey[]> {
try {
logger.info("오버라이드 키 목록 조회 시작", { companyCode });
const keys = await query<{
key_id: number;
company_code: string;
lang_key: string;
category_id: number | null;
key_meaning: string | null;
usage_note: string | null;
base_key_id: number | null;
is_active: string;
created_date: Date | null;
}>(
`SELECT key_id, company_code, lang_key, category_id, key_meaning,
usage_note, base_key_id, is_active, created_date
FROM multi_lang_key_master
WHERE company_code = $1 AND base_key_id IS NOT NULL
ORDER BY lang_key ASC`,
[companyCode]
);
return keys.map((k) => ({
keyId: k.key_id,
companyCode: k.company_code,
langKey: k.lang_key,
categoryId: k.category_id ?? undefined,
keyMeaning: k.key_meaning ?? undefined,
usageNote: k.usage_note ?? undefined,
baseKeyId: k.base_key_id ?? undefined,
isActive: k.is_active,
createdDate: k.created_date ?? undefined,
}));
} catch (error) {
logger.error("오버라이드 키 목록 조회 중 오류 발생:", error);
throw new Error(
`오버라이드 키 목록 조회 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 키 존재 여부 및 미리보기 확인
*/
async previewGeneratedKey(categoryId: number, keyMeaning: string, companyCode: string): Promise<{
langKey: string;
exists: boolean;
isOverride: boolean;
baseKeyId?: number;
}> {
try {
// 카테고리 경로 조회
const categoryPath = await this.getCategoryPath(categoryId);
if (categoryPath.length === 0) {
throw new Error("존재하지 않는 카테고리입니다");
}
// lang_key 생성
const prefixes = categoryPath.map((c) => c.keyPrefix);
const langKey = [...prefixes, keyMeaning].join(".");
// 공통 키 확인
const commonKey = await queryOne<{ key_id: number }>(
`SELECT key_id FROM multi_lang_key_master
WHERE company_code = '*' AND lang_key = $1`,
[langKey]
);
// 회사별 키 확인
const companyKey = await queryOne<{ key_id: number }>(
`SELECT key_id FROM multi_lang_key_master
WHERE company_code = $1 AND lang_key = $2`,
[companyCode, langKey]
);
return {
langKey,
exists: !!companyKey,
isOverride: !!commonKey && !companyKey,
baseKeyId: commonKey?.key_id,
};
} catch (error) {
logger.error("키 미리보기 중 오류 발생:", error);
throw new Error(
`키 미리보기 실패: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
/**
* 언어 목록 조회
*/
@@ -275,14 +692,14 @@ export class MultiLangService {
// 메뉴 코드 필터
if (params.menuCode) {
whereConditions.push(`menu_name = $${paramIndex++}`);
whereConditions.push(`usage_note = $${paramIndex++}`);
values.push(params.menuCode);
}
// 검색 조건 (OR)
if (params.searchText) {
whereConditions.push(
`(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR menu_name ILIKE $${paramIndex})`
`(lang_key ILIKE $${paramIndex} OR description ILIKE $${paramIndex} OR usage_note ILIKE $${paramIndex})`
);
values.push(`%${params.searchText}%`);
paramIndex++;
@@ -296,7 +713,7 @@ export class MultiLangService {
const langKeys = await query<{
key_id: number;
company_code: string;
menu_name: string | null;
usage_note: string | null;
lang_key: string;
description: string | null;
is_active: string | null;
@@ -305,18 +722,18 @@ export class MultiLangService {
updated_date: Date | null;
updated_by: string | null;
}>(
`SELECT key_id, company_code, menu_name, lang_key, description, is_active,
`SELECT key_id, company_code, usage_note, lang_key, description, is_active,
created_date, created_by, updated_date, updated_by
FROM multi_lang_key_master
${whereClause}
ORDER BY company_code ASC, menu_name ASC, lang_key ASC`,
ORDER BY company_code ASC, usage_note ASC, lang_key ASC`,
values
);
const mappedKeys: LangKey[] = langKeys.map((key) => ({
keyId: key.key_id,
companyCode: key.company_code,
menuName: key.menu_name || undefined,
menuName: key.usage_note || undefined,
langKey: key.lang_key,
description: key.description || undefined,
isActive: key.is_active || "Y",
@@ -407,7 +824,7 @@ export class MultiLangService {
// 다국어 키 생성
const createdKey = await queryOne<{ key_id: number }>(
`INSERT INTO multi_lang_key_master
(company_code, menu_name, lang_key, description, is_active, created_by, updated_by)
(company_code, usage_note, lang_key, description, is_active, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING key_id`,
[
@@ -480,7 +897,7 @@ export class MultiLangService {
values.push(keyData.companyCode);
}
if (keyData.menuName !== undefined) {
updates.push(`menu_name = $${paramIndex++}`);
updates.push(`usage_note = $${paramIndex++}`);
values.push(keyData.menuName);
}
if (keyData.langKey) {
@@ -668,7 +1085,7 @@ export class MultiLangService {
WHERE mlt.lang_code = $1
AND mlt.is_active = $2
AND mlkm.company_code = $3
AND mlkm.menu_name = $4
AND mlkm.usage_note = $4
AND mlkm.lang_key = $5
AND mlkm.is_active = $6`,
[
@@ -753,7 +1170,8 @@ export class MultiLangService {
}
/**
* 배치 번역 조회
* 배치 번역 조회 (회사별 우선순위 적용)
* 우선순위: 회사별 키 > 공통 키(*)
*/
async getBatchTranslations(
params: BatchTranslationRequest
@@ -775,12 +1193,17 @@ export class MultiLangService {
.map((_, i) => `$${i + 4}`)
.join(", ");
// 회사별 우선순위를 적용하기 위해 정렬 수정
// 회사별 키가 먼저 오도록 DESC 정렬 (company_code가 '*'보다 특정 회사 코드가 알파벳 순으로 앞)
// 또는 CASE WHEN을 사용하여 명시적으로 우선순위 설정
const translations = await query<{
lang_text: string;
lang_key: string;
company_code: string;
priority: number;
}>(
`SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code
`SELECT mlt.lang_text, mlkm.lang_key, mlkm.company_code,
CASE WHEN mlkm.company_code = $3 THEN 1 ELSE 2 END as priority
FROM multi_lang_text mlt
INNER JOIN multi_lang_key_master mlkm ON mlt.key_id = mlkm.key_id
WHERE mlt.lang_code = $1
@@ -788,7 +1211,7 @@ export class MultiLangService {
AND mlkm.lang_key IN (${placeholders})
AND mlkm.company_code IN ($3, '*')
AND mlkm.is_active = $2
ORDER BY mlkm.company_code ASC`,
ORDER BY mlkm.lang_key ASC, priority ASC`,
[params.userLang, "Y", params.companyCode, ...params.langKeys]
);
@@ -799,17 +1222,22 @@ export class MultiLangService {
result[key] = key;
});
// 실제 번역으로 덮어쓰기 (회사별 우선)
// 우선순위 기반으로 번역 적용
// priority가 낮은 것(회사별)이 먼저 오므로, 먼저 처리된 키는 덮어쓰지 않음
const processedKeys = new Set<string>();
translations.forEach((translation) => {
const langKey = translation.lang_key;
if (params.langKeys.includes(langKey)) {
if (params.langKeys.includes(langKey) && !processedKeys.has(langKey)) {
result[langKey] = translation.lang_text;
processedKeys.add(langKey);
}
});
logger.info("배치 번역 조회 완료", {
totalKeys: params.langKeys.length,
foundTranslations: translations.length,
companyOverrides: translations.filter(t => t.company_code !== '*').length,
resultKeys: Object.keys(result).length,
});