카테고리 기능 구현
This commit is contained in:
@@ -66,6 +66,7 @@ import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -226,6 +227,7 @@ app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
||||
246
backend-node/src/controllers/tableCategoryValueController.ts
Normal file
246
backend-node/src/controllers/tableCategoryValueController.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { Request, Response } from "express";
|
||||
import tableCategoryValueService from "../services/tableCategoryValueService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
export const getCategoryColumns = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName } = req.params;
|
||||
|
||||
const columns = await tableCategoryValueService.getCategoryColumns(
|
||||
tableName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: columns,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 컬럼 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 컬럼 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||
*/
|
||||
export const getCategoryValues = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
const menuId = parseInt(req.query.menuId as string, 10);
|
||||
const includeInactive = req.query.includeInactive === "true";
|
||||
|
||||
if (!menuId || isNaN(menuId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "menuId 파라미터가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
const values = await tableCategoryValueService.getCategoryValues(
|
||||
tableName,
|
||||
columnName,
|
||||
menuId,
|
||||
companyCode,
|
||||
includeInactive
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: values,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 조회 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 조회 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 추가
|
||||
*/
|
||||
export const addCategoryValue = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const value = req.body;
|
||||
|
||||
const newValue = await tableCategoryValueService.addCategoryValue(
|
||||
value,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: newValue,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 추가 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "카테고리 값 추가 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
export const updateCategoryValue = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const valueId = parseInt(req.params.valueId);
|
||||
const updates = req.body;
|
||||
|
||||
if (isNaN(valueId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 값 ID입니다",
|
||||
});
|
||||
}
|
||||
|
||||
const updatedValue = await tableCategoryValueService.updateCategoryValue(
|
||||
valueId,
|
||||
updates,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: updatedValue,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 수정 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 수정 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제
|
||||
*/
|
||||
export const deleteCategoryValue = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const valueId = parseInt(req.params.valueId);
|
||||
|
||||
if (isNaN(valueId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 값 ID입니다",
|
||||
});
|
||||
}
|
||||
|
||||
await tableCategoryValueService.deleteCategoryValue(
|
||||
valueId,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "카테고리 값이 삭제되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 일괄 삭제
|
||||
*/
|
||||
export const bulkDeleteCategoryValues = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { valueIds } = req.body;
|
||||
|
||||
if (!Array.isArray(valueIds) || valueIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "삭제할 값 ID 목록이 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
await tableCategoryValueService.bulkDeleteCategoryValues(
|
||||
valueIds,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 카테고리 값 순서 변경
|
||||
*/
|
||||
export const reorderCategoryValues = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { orderedValueIds } = req.body;
|
||||
|
||||
if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "순서 정보가 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
await tableCategoryValueService.reorderCategoryValues(
|
||||
orderedValueIds,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "카테고리 값 순서가 변경되었습니다",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 순서 변경 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "카테고리 값 순서 변경 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
50
backend-node/src/routes/tableCategoryValueRoutes.ts
Normal file
50
backend-node/src/routes/tableCategoryValueRoutes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router } from "express";
|
||||
import * as tableCategoryValueController from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 테이블의 카테고리 컬럼 목록 조회
|
||||
router.get(
|
||||
"/:tableName/columns",
|
||||
tableCategoryValueController.getCategoryColumns
|
||||
);
|
||||
|
||||
// 카테고리 값 목록 조회
|
||||
router.get(
|
||||
"/:tableName/:columnName/values",
|
||||
tableCategoryValueController.getCategoryValues
|
||||
);
|
||||
|
||||
// 카테고리 값 추가
|
||||
router.post("/values", tableCategoryValueController.addCategoryValue);
|
||||
|
||||
// 카테고리 값 수정
|
||||
router.put(
|
||||
"/values/:valueId",
|
||||
tableCategoryValueController.updateCategoryValue
|
||||
);
|
||||
|
||||
// 카테고리 값 삭제
|
||||
router.delete(
|
||||
"/values/:valueId",
|
||||
tableCategoryValueController.deleteCategoryValue
|
||||
);
|
||||
|
||||
// 카테고리 값 일괄 삭제
|
||||
router.post(
|
||||
"/values/bulk-delete",
|
||||
tableCategoryValueController.bulkDeleteCategoryValues
|
||||
);
|
||||
|
||||
// 카테고리 값 순서 변경
|
||||
router.post(
|
||||
"/values/reorder",
|
||||
tableCategoryValueController.reorderCategoryValues
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/**
|
||||
* 동적 데이터 서비스
|
||||
*
|
||||
* 주요 특징:
|
||||
* 1. 화이트리스트 제거 - 모든 테이블에 동적으로 접근 가능
|
||||
* 2. 블랙리스트 방식 - 시스템 중요 테이블만 접근 금지
|
||||
* 3. 자동 회사별 필터링 - company_code 컬럼 자동 감지 및 필터 적용
|
||||
* 4. SQL 인젝션 방지 - 정규식 기반 테이블명/컬럼명 검증
|
||||
*
|
||||
* 보안:
|
||||
* - 테이블명은 영문, 숫자, 언더스코어만 허용
|
||||
* - 시스템 테이블(pg_*, information_schema 등) 접근 금지
|
||||
* - company_code 컬럼이 있는 테이블은 자동으로 회사별 격리
|
||||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||
*/
|
||||
import { query, queryOne } from "../database/db";
|
||||
|
||||
interface GetTableDataParams {
|
||||
@@ -17,65 +32,72 @@ interface ServiceResponse<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 안전한 테이블명 목록 (화이트리스트)
|
||||
* SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능
|
||||
* 접근 금지 테이블 목록 (블랙리스트)
|
||||
* 시스템 중요 테이블 및 보안상 접근 금지할 테이블
|
||||
*/
|
||||
const ALLOWED_TABLES = [
|
||||
"company_mng",
|
||||
"user_info",
|
||||
"dept_info",
|
||||
"code_info",
|
||||
"code_category",
|
||||
"menu_info",
|
||||
"approval",
|
||||
"approval_kind",
|
||||
"board",
|
||||
"comm_code",
|
||||
"product_mng",
|
||||
"part_mng",
|
||||
"material_mng",
|
||||
"order_mng_master",
|
||||
"inventory_mng",
|
||||
"contract_mgmt",
|
||||
"project_mgmt",
|
||||
"screen_definitions",
|
||||
"screen_layouts",
|
||||
"layout_standards",
|
||||
"component_standards",
|
||||
"web_type_standards",
|
||||
"button_action_standards",
|
||||
"template_standards",
|
||||
"grid_standards",
|
||||
"style_templates",
|
||||
"multi_lang_key_master",
|
||||
"multi_lang_text",
|
||||
"language_master",
|
||||
"table_labels",
|
||||
"column_labels",
|
||||
"dynamic_form_data",
|
||||
"work_history", // 작업 이력 테이블
|
||||
"delivery_status", // 배송 현황 테이블
|
||||
const BLOCKED_TABLES = [
|
||||
"pg_catalog",
|
||||
"pg_statistic",
|
||||
"pg_database",
|
||||
"pg_user",
|
||||
"information_schema",
|
||||
"session_tokens", // 세션 토큰 테이블
|
||||
"password_history", // 패스워드 이력
|
||||
];
|
||||
|
||||
/**
|
||||
* 회사별 필터링이 필요한 테이블 목록
|
||||
* 테이블 이름 검증 정규식
|
||||
* SQL 인젝션 방지: 영문, 숫자, 언더스코어만 허용
|
||||
*/
|
||||
const COMPANY_FILTERED_TABLES = [
|
||||
"company_mng",
|
||||
"user_info",
|
||||
"dept_info",
|
||||
"approval",
|
||||
"board",
|
||||
"product_mng",
|
||||
"part_mng",
|
||||
"material_mng",
|
||||
"order_mng_master",
|
||||
"inventory_mng",
|
||||
"contract_mgmt",
|
||||
"project_mgmt",
|
||||
];
|
||||
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
class DataService {
|
||||
/**
|
||||
* 테이블 접근 검증 (공통 메서드)
|
||||
*/
|
||||
private async validateTableAccess(
|
||||
tableName: string
|
||||
): Promise<{ valid: boolean; error?: ServiceResponse<any> }> {
|
||||
// 1. 테이블명 형식 검증 (SQL 인젝션 방지)
|
||||
if (!TABLE_NAME_REGEX.test(tableName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
success: false,
|
||||
message: `유효하지 않은 테이블명입니다: ${tableName}`,
|
||||
error: "INVALID_TABLE_NAME",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 블랙리스트 검증
|
||||
if (BLOCKED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
success: false,
|
||||
message: `접근이 금지된 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_ACCESS_DENIED",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 3. 테이블 존재 여부 확인
|
||||
const tableExists = await this.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
return {
|
||||
valid: false,
|
||||
error: {
|
||||
success: false,
|
||||
message: `테이블을 찾을 수 없습니다: ${tableName}`,
|
||||
error: "TABLE_NOT_FOUND",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회
|
||||
*/
|
||||
@@ -92,23 +114,10 @@ class DataService {
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// 테이블 존재 여부 확인
|
||||
const tableExists = await this.checkTableExists(tableName);
|
||||
if (!tableExists) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블을 찾을 수 없습니다: ${tableName}`,
|
||||
error: "TABLE_NOT_FOUND",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// 동적 SQL 쿼리 생성
|
||||
@@ -119,13 +128,14 @@ class DataService {
|
||||
// WHERE 조건 생성
|
||||
const whereConditions: string[] = [];
|
||||
|
||||
// 회사별 필터링 추가
|
||||
if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) {
|
||||
// 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용
|
||||
if (userCompany !== "*") {
|
||||
// 4. 회사별 필터링 자동 적용 (company_code 컬럼이 있는 경우)
|
||||
if (userCompany && userCompany !== "*") {
|
||||
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||
if (hasCompanyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
queryParams.push(userCompany);
|
||||
paramIndex++;
|
||||
console.log(`🏢 회사별 필터링 적용: ${tableName}.company_code = ${userCompany}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,13 +223,10 @@ class DataService {
|
||||
*/
|
||||
async getTableColumns(tableName: string): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
const columns = await this.getTableColumnsSimple(tableName);
|
||||
@@ -276,6 +283,31 @@ class DataService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼 존재 여부 확인
|
||||
*/
|
||||
private async checkColumnExists(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
AND column_name = $2
|
||||
)`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
return result[0]?.exists || false;
|
||||
} catch (error) {
|
||||
console.error("컬럼 존재 확인 오류:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회 (간단 버전)
|
||||
*/
|
||||
@@ -324,13 +356,10 @@ class DataService {
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
@@ -383,21 +412,16 @@ class DataService {
|
||||
leftValue?: string | number
|
||||
): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(leftTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 왼쪽 테이블 접근 검증
|
||||
const leftValidation = await this.validateTableAccess(leftTable);
|
||||
if (!leftValidation.valid) {
|
||||
return leftValidation.error!;
|
||||
}
|
||||
|
||||
if (!ALLOWED_TABLES.includes(rightTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 오른쪽 테이블 접근 검증
|
||||
const rightValidation = await this.validateTableAccess(rightTable);
|
||||
if (!rightValidation.valid) {
|
||||
return rightValidation.error!;
|
||||
}
|
||||
|
||||
let queryText = `
|
||||
@@ -440,13 +464,10 @@ class DataService {
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
@@ -485,13 +506,10 @@ class DataService {
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
@@ -554,13 +572,10 @@ class DataService {
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<void>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
// 테이블 접근 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
|
||||
497
backend-node/src/services/tableCategoryValueService.ts
Normal file
497
backend-node/src/services/tableCategoryValueService.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import {
|
||||
TableCategoryValue,
|
||||
CategoryColumn,
|
||||
} from "../types/tableCategoryValue";
|
||||
|
||||
class TableCategoryValueService {
|
||||
/**
|
||||
* 메뉴의 형제 메뉴 ID 목록 조회
|
||||
* (같은 부모를 가진 메뉴들)
|
||||
*/
|
||||
async getSiblingMenuIds(menuId: number): Promise<number[]> {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 현재 메뉴의 부모 ID 조회 (menu_info는 objid와 parent_obj_id 사용)
|
||||
const parentQuery = `
|
||||
SELECT parent_obj_id FROM menu_info WHERE objid = $1
|
||||
`;
|
||||
const parentResult = await pool.query(parentQuery, [menuId]);
|
||||
|
||||
if (parentResult.rows.length === 0) {
|
||||
logger.warn(`메뉴 ID ${menuId}를 찾을 수 없습니다`);
|
||||
return [menuId];
|
||||
}
|
||||
|
||||
const parentId = parentResult.rows[0].parent_obj_id;
|
||||
|
||||
// 최상위 메뉴인 경우 (parent_obj_id가 null 또는 0)
|
||||
if (!parentId || parentId === 0) {
|
||||
logger.info(`메뉴 ${menuId}는 최상위 메뉴입니다`);
|
||||
return [menuId];
|
||||
}
|
||||
|
||||
// 2. 같은 부모를 가진 형제 메뉴들 조회
|
||||
const siblingsQuery = `
|
||||
SELECT objid FROM menu_info WHERE parent_obj_id = $1
|
||||
`;
|
||||
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
|
||||
|
||||
const siblingIds = siblingsResult.rows.map((row) => Number(row.objid));
|
||||
|
||||
logger.info(`메뉴 ${menuId}의 형제 메뉴 ${siblingIds.length}개 조회`, {
|
||||
menuId,
|
||||
parentId,
|
||||
siblings: siblingIds,
|
||||
});
|
||||
|
||||
return siblingIds;
|
||||
} catch (error: any) {
|
||||
logger.error(`형제 메뉴 조회 실패: ${error.message}`);
|
||||
// 에러 시 현재 메뉴만 반환
|
||||
return [menuId];
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 테이블의 카테고리 타입 컬럼 목록 조회
|
||||
*/
|
||||
async getCategoryColumns(
|
||||
tableName: string,
|
||||
companyCode: string
|
||||
): Promise<CategoryColumn[]> {
|
||||
try {
|
||||
logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode });
|
||||
|
||||
const pool = getPool();
|
||||
const query = `
|
||||
SELECT
|
||||
tc.table_name AS "tableName",
|
||||
tc.column_name AS "columnName",
|
||||
tc.column_name AS "columnLabel",
|
||||
COUNT(cv.value_id) AS "valueCount"
|
||||
FROM table_type_columns tc
|
||||
LEFT JOIN table_column_category_values cv
|
||||
ON tc.table_name = cv.table_name
|
||||
AND tc.column_name = cv.column_name
|
||||
AND cv.is_active = true
|
||||
AND (cv.company_code = $2 OR cv.company_code = '*')
|
||||
WHERE tc.table_name = $1
|
||||
AND tc.input_type = 'category'
|
||||
GROUP BY tc.table_name, tc.column_name, tc.display_order
|
||||
ORDER BY tc.display_order, tc.column_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [tableName, companyCode]);
|
||||
|
||||
logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
|
||||
tableName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
return result.rows;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 컬럼 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프 적용)
|
||||
*/
|
||||
async getCategoryValues(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
menuId: number,
|
||||
companyCode: string,
|
||||
includeInactive: boolean = false
|
||||
): Promise<TableCategoryValue[]> {
|
||||
try {
|
||||
logger.info("카테고리 값 목록 조회", {
|
||||
tableName,
|
||||
columnName,
|
||||
menuId,
|
||||
companyCode,
|
||||
includeInactive,
|
||||
});
|
||||
|
||||
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
|
||||
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
|
||||
|
||||
const pool = getPool();
|
||||
let query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
menu_objid AS "menuId",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND menu_objid = ANY($3)
|
||||
AND (company_code = $4 OR company_code = '*')
|
||||
`;
|
||||
|
||||
const params: any[] = [tableName, columnName, siblingMenuIds, companyCode];
|
||||
|
||||
if (!includeInactive) {
|
||||
query += ` AND is_active = true`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY value_order, value_label`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// 계층 구조로 변환
|
||||
const values = this.buildHierarchy(result.rows);
|
||||
|
||||
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
|
||||
tableName,
|
||||
columnName,
|
||||
menuId,
|
||||
siblingMenuIds,
|
||||
});
|
||||
|
||||
return values;
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 조회 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 추가
|
||||
*/
|
||||
async addCategoryValue(
|
||||
value: TableCategoryValue,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TableCategoryValue> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 중복 코드 체크
|
||||
const duplicateQuery = `
|
||||
SELECT value_id
|
||||
FROM table_column_category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
AND value_code = $3
|
||||
AND (company_code = $4 OR company_code = '*')
|
||||
`;
|
||||
|
||||
const duplicateResult = await pool.query(duplicateQuery, [
|
||||
value.tableName,
|
||||
value.columnName,
|
||||
value.valueCode,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (duplicateResult.rows.length > 0) {
|
||||
throw new Error("이미 존재하는 코드입니다");
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO table_column_category_values (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, description, color, icon,
|
||||
is_active, is_default, menu_objid, company_code, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
menu_objid AS "menuId",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
created_by AS "createdBy"
|
||||
`;
|
||||
|
||||
const result = await pool.query(insertQuery, [
|
||||
value.tableName,
|
||||
value.columnName,
|
||||
value.valueCode,
|
||||
value.valueLabel,
|
||||
value.valueOrder || 0,
|
||||
value.parentValueId || null,
|
||||
value.depth || 1,
|
||||
value.description || null,
|
||||
value.color || null,
|
||||
value.icon || null,
|
||||
value.isActive !== false,
|
||||
value.isDefault || false,
|
||||
value.menuId, // menuId 추가
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("카테고리 값 추가 완료", {
|
||||
valueId: result.rows[0].valueId,
|
||||
tableName: value.tableName,
|
||||
columnName: value.columnName,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 추가 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
async updateCategoryValue(
|
||||
valueId: number,
|
||||
updates: Partial<TableCategoryValue>,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<TableCategoryValue> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const setClauses: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (updates.valueLabel !== undefined) {
|
||||
setClauses.push(`value_label = $${paramIndex++}`);
|
||||
values.push(updates.valueLabel);
|
||||
}
|
||||
|
||||
if (updates.valueOrder !== undefined) {
|
||||
setClauses.push(`value_order = $${paramIndex++}`);
|
||||
values.push(updates.valueOrder);
|
||||
}
|
||||
|
||||
if (updates.description !== undefined) {
|
||||
setClauses.push(`description = $${paramIndex++}`);
|
||||
values.push(updates.description);
|
||||
}
|
||||
|
||||
if (updates.color !== undefined) {
|
||||
setClauses.push(`color = $${paramIndex++}`);
|
||||
values.push(updates.color);
|
||||
}
|
||||
|
||||
if (updates.icon !== undefined) {
|
||||
setClauses.push(`icon = $${paramIndex++}`);
|
||||
values.push(updates.icon);
|
||||
}
|
||||
|
||||
if (updates.isActive !== undefined) {
|
||||
setClauses.push(`is_active = $${paramIndex++}`);
|
||||
values.push(updates.isActive);
|
||||
}
|
||||
|
||||
if (updates.isDefault !== undefined) {
|
||||
setClauses.push(`is_default = $${paramIndex++}`);
|
||||
values.push(updates.isDefault);
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
setClauses.push(`updated_by = $${paramIndex++}`);
|
||||
values.push(userId);
|
||||
|
||||
values.push(valueId, companyCode);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE value_id = $${paramIndex++}
|
||||
AND (company_code = $${paramIndex++} OR company_code = '*')
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
menu_objid AS "menuId",
|
||||
updated_at AS "updatedAt",
|
||||
updated_by AS "updatedBy"
|
||||
`;
|
||||
|
||||
const result = await pool.query(updateQuery, values);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new Error("카테고리 값을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 수정 완료", { valueId, companyCode });
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 수정 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (비활성화)
|
||||
*/
|
||||
async deleteCategoryValue(
|
||||
valueId: number,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 하위 값 체크
|
||||
const checkQuery = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM table_column_category_values
|
||||
WHERE parent_value_id = $1
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
AND is_active = true
|
||||
`;
|
||||
|
||||
const checkResult = await pool.query(checkQuery, [valueId, companyCode]);
|
||||
|
||||
if (parseInt(checkResult.rows[0].count) > 0) {
|
||||
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
|
||||
}
|
||||
|
||||
// 비활성화
|
||||
const deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $3
|
||||
WHERE value_id = $1
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
`;
|
||||
|
||||
await pool.query(deleteQuery, [valueId, companyCode, userId]);
|
||||
|
||||
logger.info("카테고리 값 삭제(비활성화) 완료", {
|
||||
valueId,
|
||||
companyCode,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 일괄 삭제
|
||||
*/
|
||||
async bulkDeleteCategoryValues(
|
||||
valueIds: number[],
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const deleteQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET is_active = false, updated_at = NOW(), updated_by = $3
|
||||
WHERE value_id = ANY($1::int[])
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
`;
|
||||
|
||||
await pool.query(deleteQuery, [valueIds, companyCode, userId]);
|
||||
|
||||
logger.info("카테고리 값 일괄 삭제 완료", {
|
||||
count: valueIds.length,
|
||||
companyCode,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 순서 변경
|
||||
*/
|
||||
async reorderCategoryValues(
|
||||
orderedValueIds: number[],
|
||||
companyCode: string
|
||||
): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
for (let i = 0; i < orderedValueIds.length; i++) {
|
||||
const updateQuery = `
|
||||
UPDATE table_column_category_values
|
||||
SET value_order = $1, updated_at = NOW()
|
||||
WHERE value_id = $2
|
||||
AND (company_code = $3 OR company_code = '*')
|
||||
`;
|
||||
|
||||
await client.query(updateQuery, [
|
||||
i + 1,
|
||||
orderedValueIds[i],
|
||||
companyCode,
|
||||
]);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("카테고리 값 순서 변경 완료", {
|
||||
count: orderedValueIds.length,
|
||||
companyCode,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error(`카테고리 값 순서 변경 실패: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계층 구조 변환 헬퍼
|
||||
*/
|
||||
private buildHierarchy(
|
||||
values: TableCategoryValue[],
|
||||
parentId: number | null = null
|
||||
): TableCategoryValue[] {
|
||||
return values
|
||||
.filter((v) => v.parentValueId === parentId)
|
||||
.map((v) => ({
|
||||
...v,
|
||||
children: this.buildHierarchy(values, v.valueId!),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export default new TableCategoryValueService();
|
||||
|
||||
48
backend-node/src/types/tableCategoryValue.ts
Normal file
48
backend-node/src/types/tableCategoryValue.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 테이블 컬럼별 카테고리 값 타입 정의
|
||||
*/
|
||||
|
||||
export interface TableCategoryValue {
|
||||
valueId?: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
|
||||
// 값 정보
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder?: number;
|
||||
|
||||
// 계층 구조
|
||||
parentValueId?: number;
|
||||
depth?: number;
|
||||
|
||||
// 추가 정보
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
|
||||
// 하위 항목 (조회 시)
|
||||
children?: TableCategoryValue[];
|
||||
|
||||
// 메뉴 스코프
|
||||
menuId: number;
|
||||
|
||||
// 멀티테넌시
|
||||
companyCode?: string;
|
||||
|
||||
// 메타
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface CategoryColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
valueCount?: number; // 값 개수
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user