feat: Phase 3.3 ComponentStandardService Raw Query 전환 완료
15개 Prisma 호출을 모두 Raw Query로 전환 - 컴포넌트 조회 (getComponents, getComponent) - 컴포넌트 CRUD (createComponent, updateComponent, deleteComponent) - 정렬 순서 업데이트 (updateSortOrder) - 컴포넌트 복제 (duplicateComponent) - 카테고리 조회 (getCategories) - 통계 조회 (getStatistics) - 중복 체크 (checkDuplicate) 주요 기술적 해결: - 동적 WHERE 조건 생성 (ILIKE 검색, OR 조건) - 동적 UPDATE 쿼리 (fieldMapping 사용) - GROUP BY 집계 쿼리 (카테고리별, 상태별) - DISTINCT 쿼리 (카테고리 목록) - 트랜잭션 처리 (정렬 순서 업데이트) - SQL 인젝션 방지 (정렬 컬럼 검증) TypeScript 컴파일 성공 Prisma import 완전 제거 Phase 3 진행률: 54/162 (33.3%) 전체 진행률: 305/444 (68.7%)
This commit is contained in:
parent
c37b74a8bb
commit
2331e3fd20
|
|
@ -1,6 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export interface ComponentStandardData {
|
export interface ComponentStandardData {
|
||||||
component_code: string;
|
component_code: string;
|
||||||
|
|
@ -49,49 +47,78 @@ class ComponentStandardService {
|
||||||
offset = 0,
|
offset = 0,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const where: any = {};
|
const whereConditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 활성화 상태 필터
|
// 활성화 상태 필터
|
||||||
if (active) {
|
if (active) {
|
||||||
where.is_active = active;
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 필터
|
// 카테고리 필터
|
||||||
if (category && category !== "all") {
|
if (category && category !== "all") {
|
||||||
where.category = category;
|
whereConditions.push(`category = $${paramIndex++}`);
|
||||||
|
values.push(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공개 여부 필터
|
// 공개 여부 필터
|
||||||
if (is_public) {
|
if (is_public) {
|
||||||
where.is_public = is_public;
|
whereConditions.push(`is_public = $${paramIndex++}`);
|
||||||
|
values.push(is_public);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트)
|
// 회사별 필터 (공개 컴포넌트 + 해당 회사 컴포넌트)
|
||||||
if (company_code) {
|
if (company_code) {
|
||||||
where.OR = [{ is_public: "Y" }, { company_code }];
|
whereConditions.push(
|
||||||
|
`(is_public = 'Y' OR company_code = $${paramIndex++})`
|
||||||
|
);
|
||||||
|
values.push(company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건
|
// 검색 조건
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
whereConditions.push(
|
||||||
...(where.OR || []),
|
`(component_name ILIKE $${paramIndex} OR component_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
{ component_name: { contains: search, mode: "insensitive" } },
|
);
|
||||||
{ component_name_eng: { contains: search, mode: "insensitive" } },
|
values.push(`%${search}%`);
|
||||||
{ description: { contains: search, mode: "insensitive" } },
|
paramIndex++;
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy: any = {};
|
const whereClause =
|
||||||
orderBy[sort] = order;
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const components = await prisma.component_standards.findMany({
|
// 정렬 컬럼 검증 (SQL 인젝션 방지)
|
||||||
where,
|
const validSortColumns = [
|
||||||
orderBy,
|
"sort_order",
|
||||||
take: limit,
|
"component_name",
|
||||||
skip: offset,
|
"category",
|
||||||
});
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
];
|
||||||
|
const sortColumn = validSortColumns.includes(sort) ? sort : "sort_order";
|
||||||
|
const sortOrder = order === "desc" ? "DESC" : "ASC";
|
||||||
|
|
||||||
const total = await prisma.component_standards.count({ where });
|
// 컴포넌트 조회
|
||||||
|
const components = await query<any>(
|
||||||
|
`SELECT * FROM component_standards
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY ${sortColumn} ${sortOrder}
|
||||||
|
${limit ? `LIMIT $${paramIndex++}` : ""}
|
||||||
|
${limit ? `OFFSET $${paramIndex++}` : ""}`,
|
||||||
|
limit ? [...values, limit, offset] : values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
components,
|
components,
|
||||||
|
|
@ -105,9 +132,10 @@ class ComponentStandardService {
|
||||||
* 컴포넌트 상세 조회
|
* 컴포넌트 상세 조회
|
||||||
*/
|
*/
|
||||||
async getComponent(component_code: string) {
|
async getComponent(component_code: string) {
|
||||||
const component = await prisma.component_standards.findUnique({
|
const component = await queryOne<any>(
|
||||||
where: { component_code },
|
`SELECT * FROM component_standards WHERE component_code = $1`,
|
||||||
});
|
[component_code]
|
||||||
|
);
|
||||||
|
|
||||||
if (!component) {
|
if (!component) {
|
||||||
throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`);
|
throw new Error(`컴포넌트를 찾을 수 없습니다: ${component_code}`);
|
||||||
|
|
@ -121,9 +149,10 @@ class ComponentStandardService {
|
||||||
*/
|
*/
|
||||||
async createComponent(data: ComponentStandardData) {
|
async createComponent(data: ComponentStandardData) {
|
||||||
// 중복 코드 확인
|
// 중복 코드 확인
|
||||||
const existing = await prisma.component_standards.findUnique({
|
const existing = await queryOne<any>(
|
||||||
where: { component_code: data.component_code },
|
`SELECT * FROM component_standards WHERE component_code = $1`,
|
||||||
});
|
[data.component_code]
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -138,13 +167,31 @@ class ComponentStandardService {
|
||||||
delete (createData as any).active;
|
delete (createData as any).active;
|
||||||
}
|
}
|
||||||
|
|
||||||
const component = await prisma.component_standards.create({
|
const component = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO component_standards
|
||||||
...createData,
|
(component_code, component_name, component_name_eng, description, category,
|
||||||
created_date: new Date(),
|
icon_name, default_size, component_config, preview_image, sort_order,
|
||||||
updated_date: new Date(),
|
is_active, is_public, company_code, created_by, updated_by, created_date, updated_date)
|
||||||
},
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())
|
||||||
});
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
createData.component_code,
|
||||||
|
createData.component_name,
|
||||||
|
createData.component_name_eng || null,
|
||||||
|
createData.description || null,
|
||||||
|
createData.category,
|
||||||
|
createData.icon_name || null,
|
||||||
|
createData.default_size || null,
|
||||||
|
createData.component_config,
|
||||||
|
createData.preview_image || null,
|
||||||
|
createData.sort_order || 0,
|
||||||
|
createData.is_active || "Y",
|
||||||
|
createData.is_public || "N",
|
||||||
|
createData.company_code,
|
||||||
|
createData.created_by || null,
|
||||||
|
createData.updated_by || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
@ -165,13 +212,41 @@ class ComponentStandardService {
|
||||||
delete (updateData as any).active;
|
delete (updateData as any).active;
|
||||||
}
|
}
|
||||||
|
|
||||||
const component = await prisma.component_standards.update({
|
// 동적 UPDATE 쿼리 생성
|
||||||
where: { component_code },
|
const updateFields: string[] = ["updated_date = NOW()"];
|
||||||
data: {
|
const values: any[] = [];
|
||||||
...updateData,
|
let paramIndex = 1;
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
const fieldMapping: { [key: string]: string } = {
|
||||||
});
|
component_name: "component_name",
|
||||||
|
component_name_eng: "component_name_eng",
|
||||||
|
description: "description",
|
||||||
|
category: "category",
|
||||||
|
icon_name: "icon_name",
|
||||||
|
default_size: "default_size",
|
||||||
|
component_config: "component_config",
|
||||||
|
preview_image: "preview_image",
|
||||||
|
sort_order: "sort_order",
|
||||||
|
is_active: "is_active",
|
||||||
|
is_public: "is_public",
|
||||||
|
company_code: "company_code",
|
||||||
|
updated_by: "updated_by",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, dbField] of Object.entries(fieldMapping)) {
|
||||||
|
if (key in updateData) {
|
||||||
|
updateFields.push(`${dbField} = $${paramIndex++}`);
|
||||||
|
values.push((updateData as any)[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const component = await queryOne<any>(
|
||||||
|
`UPDATE component_standards
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE component_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, component_code]
|
||||||
|
);
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
@ -182,9 +257,10 @@ class ComponentStandardService {
|
||||||
async deleteComponent(component_code: string) {
|
async deleteComponent(component_code: string) {
|
||||||
const existing = await this.getComponent(component_code);
|
const existing = await this.getComponent(component_code);
|
||||||
|
|
||||||
await prisma.component_standards.delete({
|
await query(
|
||||||
where: { component_code },
|
`DELETE FROM component_standards WHERE component_code = $1`,
|
||||||
});
|
[component_code]
|
||||||
|
);
|
||||||
|
|
||||||
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
|
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
|
||||||
}
|
}
|
||||||
|
|
@ -195,14 +271,16 @@ class ComponentStandardService {
|
||||||
async updateSortOrder(
|
async updateSortOrder(
|
||||||
updates: Array<{ component_code: string; sort_order: number }>
|
updates: Array<{ component_code: string; sort_order: number }>
|
||||||
) {
|
) {
|
||||||
const transactions = updates.map(({ component_code, sort_order }) =>
|
await transaction(async (client) => {
|
||||||
prisma.component_standards.update({
|
for (const { component_code, sort_order } of updates) {
|
||||||
where: { component_code },
|
await client.query(
|
||||||
data: { sort_order, updated_date: new Date() },
|
`UPDATE component_standards
|
||||||
})
|
SET sort_order = $1, updated_date = NOW()
|
||||||
|
WHERE component_code = $2`,
|
||||||
|
[sort_order, component_code]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
await prisma.$transaction(transactions);
|
});
|
||||||
|
|
||||||
return { message: "정렬 순서가 업데이트되었습니다." };
|
return { message: "정렬 순서가 업데이트되었습니다." };
|
||||||
}
|
}
|
||||||
|
|
@ -218,33 +296,38 @@ class ComponentStandardService {
|
||||||
const source = await this.getComponent(source_code);
|
const source = await this.getComponent(source_code);
|
||||||
|
|
||||||
// 새 코드 중복 확인
|
// 새 코드 중복 확인
|
||||||
const existing = await prisma.component_standards.findUnique({
|
const existing = await queryOne<any>(
|
||||||
where: { component_code: new_code },
|
`SELECT * FROM component_standards WHERE component_code = $1`,
|
||||||
});
|
[new_code]
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`);
|
throw new Error(`이미 존재하는 컴포넌트 코드입니다: ${new_code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const component = await prisma.component_standards.create({
|
const component = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO component_standards
|
||||||
component_code: new_code,
|
(component_code, component_name, component_name_eng, description, category,
|
||||||
component_name: new_name,
|
icon_name, default_size, component_config, preview_image, sort_order,
|
||||||
component_name_eng: source?.component_name_eng,
|
is_active, is_public, company_code, created_date, updated_date)
|
||||||
description: source?.description,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||||
category: source?.category,
|
RETURNING *`,
|
||||||
icon_name: source?.icon_name,
|
[
|
||||||
default_size: source?.default_size as any,
|
new_code,
|
||||||
component_config: source?.component_config as any,
|
new_name,
|
||||||
preview_image: source?.preview_image,
|
source?.component_name_eng,
|
||||||
sort_order: source?.sort_order,
|
source?.description,
|
||||||
is_active: source?.is_active,
|
source?.category,
|
||||||
is_public: source?.is_public,
|
source?.icon_name,
|
||||||
company_code: source?.company_code || "DEFAULT",
|
source?.default_size,
|
||||||
created_date: new Date(),
|
source?.component_config,
|
||||||
updated_date: new Date(),
|
source?.preview_image,
|
||||||
},
|
source?.sort_order,
|
||||||
});
|
source?.is_active,
|
||||||
|
source?.is_public,
|
||||||
|
source?.company_code || "DEFAULT",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
@ -253,19 +336,20 @@ class ComponentStandardService {
|
||||||
* 카테고리 목록 조회
|
* 카테고리 목록 조회
|
||||||
*/
|
*/
|
||||||
async getCategories(company_code?: string) {
|
async getCategories(company_code?: string) {
|
||||||
const where: any = {
|
const whereConditions: string[] = ["is_active = 'Y'"];
|
||||||
is_active: "Y",
|
const values: any[] = [];
|
||||||
};
|
|
||||||
|
|
||||||
if (company_code) {
|
if (company_code) {
|
||||||
where.OR = [{ is_public: "Y" }, { company_code }];
|
whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
|
||||||
|
values.push(company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = await prisma.component_standards.findMany({
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
where,
|
|
||||||
select: { category: true },
|
const categories = await query<{ category: string }>(
|
||||||
distinct: ["category"],
|
`SELECT DISTINCT category FROM component_standards ${whereClause} ORDER BY category`,
|
||||||
});
|
values
|
||||||
|
);
|
||||||
|
|
||||||
return categories
|
return categories
|
||||||
.map((item) => item.category)
|
.map((item) => item.category)
|
||||||
|
|
@ -276,36 +360,48 @@ class ComponentStandardService {
|
||||||
* 컴포넌트 통계
|
* 컴포넌트 통계
|
||||||
*/
|
*/
|
||||||
async getStatistics(company_code?: string) {
|
async getStatistics(company_code?: string) {
|
||||||
const where: any = {
|
const whereConditions: string[] = ["is_active = 'Y'"];
|
||||||
is_active: "Y",
|
const values: any[] = [];
|
||||||
};
|
|
||||||
|
|
||||||
if (company_code) {
|
if (company_code) {
|
||||||
where.OR = [{ is_public: "Y" }, { company_code }];
|
whereConditions.push(`(is_public = 'Y' OR company_code = $1)`);
|
||||||
|
values.push(company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = await prisma.component_standards.count({ where });
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
||||||
const byCategory = await prisma.component_standards.groupBy({
|
// 전체 개수
|
||||||
by: ["category"],
|
const totalResult = await queryOne<{ count: string }>(
|
||||||
where,
|
`SELECT COUNT(*) as count FROM component_standards ${whereClause}`,
|
||||||
_count: { category: true },
|
values
|
||||||
});
|
);
|
||||||
|
const total = parseInt(totalResult?.count || "0");
|
||||||
|
|
||||||
const byStatus = await prisma.component_standards.groupBy({
|
// 카테고리별 집계
|
||||||
by: ["is_active"],
|
const byCategory = await query<{ category: string; count: string }>(
|
||||||
_count: { is_active: true },
|
`SELECT category, COUNT(*) as count
|
||||||
});
|
FROM component_standards
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY category`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// 상태별 집계
|
||||||
|
const byStatus = await query<{ is_active: string; count: string }>(
|
||||||
|
`SELECT is_active, COUNT(*) as count
|
||||||
|
FROM component_standards
|
||||||
|
GROUP BY is_active`
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
byCategory: byCategory.map((item) => ({
|
byCategory: byCategory.map((item) => ({
|
||||||
category: item.category,
|
category: item.category,
|
||||||
count: item._count.category,
|
count: parseInt(item.count),
|
||||||
})),
|
})),
|
||||||
byStatus: byStatus.map((item) => ({
|
byStatus: byStatus.map((item) => ({
|
||||||
status: item.is_active,
|
status: item.is_active,
|
||||||
count: item._count.is_active,
|
count: parseInt(item.count),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -317,16 +413,21 @@ class ComponentStandardService {
|
||||||
component_code: string,
|
component_code: string,
|
||||||
company_code?: string
|
company_code?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const whereClause: any = { component_code };
|
const whereConditions: string[] = ["component_code = $1"];
|
||||||
|
const values: any[] = [component_code];
|
||||||
|
|
||||||
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
|
// 회사 코드가 있고 "*"가 아닌 경우에만 조건 추가
|
||||||
if (company_code && company_code !== "*") {
|
if (company_code && company_code !== "*") {
|
||||||
whereClause.company_code = company_code;
|
whereConditions.push("company_code = $2");
|
||||||
|
values.push(company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingComponent = await prisma.component_standards.findFirst({
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
where: whereClause,
|
|
||||||
});
|
const existingComponent = await queryOne<any>(
|
||||||
|
`SELECT * FROM component_standards ${whereClause} LIMIT 1`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
return !!existingComponent;
|
return !!existingComponent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue