다국어 관리 시스템 개선: 카테고리 및 키 자동 생성 기능 추가
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user