Merge branch 'main' into feature/batch-testing-updates
This commit is contained in:
commit
ec35ca303f
|
|
@ -150,9 +150,6 @@ jspm_packages/
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Prisma
|
|
||||||
prisma/migrations/
|
|
||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
@ -273,8 +270,6 @@ out/
|
||||||
.settings/
|
.settings/
|
||||||
bin/
|
bin/
|
||||||
|
|
||||||
/src/generated/prisma
|
|
||||||
|
|
||||||
# 업로드된 파일들 제외
|
# 업로드된 파일들 제외
|
||||||
backend-node/uploads/
|
backend-node/uploads/
|
||||||
uploads/
|
uploads/
|
||||||
|
|
@ -290,3 +285,5 @@ uploads/
|
||||||
*.pptx
|
*.pptx
|
||||||
*.hwp
|
*.hwp
|
||||||
*.hwpx
|
*.hwpx
|
||||||
|
|
||||||
|
claude.md
|
||||||
File diff suppressed because it is too large
Load Diff
12
DOCKER.md
12
DOCKER.md
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
**기술 스택:**
|
**기술 스택:**
|
||||||
|
|
||||||
- **백엔드**: Node.js + TypeScript + Prisma + PostgreSQL
|
- **백엔드**: Node.js + TypeScript + PostgreSQL (Raw Query)
|
||||||
- **프론트엔드**: Next.js + TypeScript + Tailwind CSS
|
- **프론트엔드**: Next.js + TypeScript + Tailwind CSS
|
||||||
- **컨테이너**: Docker + Docker Compose
|
- **컨테이너**: Docker + Docker Compose
|
||||||
|
|
||||||
|
|
@ -98,12 +98,12 @@ npm install / npm uninstall # 패키지 설치/제거
|
||||||
package-lock.json 변경 # 의존성 잠금 파일
|
package-lock.json 변경 # 의존성 잠금 파일
|
||||||
```
|
```
|
||||||
|
|
||||||
**Prisma 관련:**
|
**데이터베이스 관련:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
backend-node/prisma/schema.prisma # DB 스키마 변경
|
db/ilshin.pgsql # DB 스키마 파일 변경
|
||||||
npx prisma migrate # 마이그레이션 실행
|
db/00-create-roles.sh # DB 초기화 스크립트 변경
|
||||||
npx prisma generate # 클라이언트 재생성
|
# SQL 마이그레이션은 직접 실행
|
||||||
```
|
```
|
||||||
|
|
||||||
**설정 파일:**
|
**설정 파일:**
|
||||||
|
|
@ -207,7 +207,7 @@ ERP-node/
|
||||||
│ ├── backend-node/
|
│ ├── backend-node/
|
||||||
│ │ ├── Dockerfile # 프로덕션용
|
│ │ ├── Dockerfile # 프로덕션용
|
||||||
│ │ └── Dockerfile.dev # 개발용
|
│ │ └── Dockerfile.dev # 개발용
|
||||||
│ └── src/, prisma/, package.json...
|
│ └── src/, database/, package.json...
|
||||||
│
|
│
|
||||||
├── 📁 프론트엔드
|
├── 📁 프론트엔드
|
||||||
│ ├── frontend/
|
│ ├── frontend/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,733 @@
|
||||||
|
# 🔐 Phase 1.5: 인증 및 관리자 서비스 Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
Phase 2의 핵심 서비스 전환 전에 **인증 및 관리자 시스템**을 먼저 Raw Query로 전환하여 전체 시스템의 안정적인 기반을 구축합니다.
|
||||||
|
|
||||||
|
### 🎯 목표
|
||||||
|
|
||||||
|
- AuthService의 5개 Prisma 호출 제거
|
||||||
|
- AdminService의 3개 Prisma 호출 제거 (이미 Raw Query 사용 중)
|
||||||
|
- AdminController의 28개 Prisma 호출 제거
|
||||||
|
- 로그인 → 인증 → API 호출 전체 플로우 검증
|
||||||
|
|
||||||
|
### 📊 전환 대상
|
||||||
|
|
||||||
|
| 서비스 | Prisma 호출 수 | 복잡도 | 우선순위 |
|
||||||
|
|--------|----------------|--------|----------|
|
||||||
|
| AuthService | 5개 | 중간 | 🔴 최우선 |
|
||||||
|
| AdminService | 3개 | 낮음 (이미 Raw Query) | 🟢 확인만 필요 |
|
||||||
|
| AdminController | 28개 | 중간 | 🟡 2순위 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 AuthService 분석
|
||||||
|
|
||||||
|
### Prisma 사용 현황 (5개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 21: loginPwdCheck() - 사용자 비밀번호 조회
|
||||||
|
const userInfo = await prisma.user_info.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
select: { user_password: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 82: insertLoginAccessLog() - 로그인 로그 기록
|
||||||
|
await prisma.$executeRaw`INSERT INTO LOGIN_ACCESS_LOG(...)`;
|
||||||
|
|
||||||
|
// Line 126: getUserInfo() - 사용자 정보 조회
|
||||||
|
const userInfo = await prisma.user_info.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
select: { /* 20개 필드 */ },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 157: getUserInfo() - 권한 정보 조회
|
||||||
|
const authInfo = await prisma.authority_sub_user.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
include: { authority_master: { select: { auth_name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 177: getUserInfo() - 회사 정보 조회
|
||||||
|
const companyInfo = await prisma.company_mng.findFirst({
|
||||||
|
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||||
|
select: { company_name: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 핵심 메서드
|
||||||
|
|
||||||
|
1. **loginPwdCheck()** - 로그인 비밀번호 검증
|
||||||
|
- user_info 테이블 조회
|
||||||
|
- 비밀번호 암호화 비교
|
||||||
|
- 마스터 패스워드 체크
|
||||||
|
|
||||||
|
2. **insertLoginAccessLog()** - 로그인 이력 기록
|
||||||
|
- LOGIN_ACCESS_LOG 테이블 INSERT
|
||||||
|
- Raw Query 이미 사용 중 (유지)
|
||||||
|
|
||||||
|
3. **getUserInfo()** - 사용자 상세 정보 조회
|
||||||
|
- user_info 테이블 조회 (20개 필드)
|
||||||
|
- authority_sub_user + authority_master 조인 (권한)
|
||||||
|
- company_mng 테이블 조회 (회사명)
|
||||||
|
- PersonBean 타입 변환
|
||||||
|
|
||||||
|
4. **processLogin()** - 로그인 전체 프로세스
|
||||||
|
- 위 3개 메서드 조합
|
||||||
|
- JWT 토큰 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 전환 계획
|
||||||
|
|
||||||
|
### Step 1: loginPwdCheck() 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
```typescript
|
||||||
|
const userInfo = await prisma.user_info.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
select: { user_password: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const result = await query<{ user_password: string }>(
|
||||||
|
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = result.length > 0 ? result[0] : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: getUserInfo() 전환 (사용자 정보)
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
```typescript
|
||||||
|
const userInfo = await prisma.user_info.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
select: {
|
||||||
|
sabun: true,
|
||||||
|
user_id: true,
|
||||||
|
user_name: true,
|
||||||
|
// ... 20개 필드
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
```typescript
|
||||||
|
const result = await query<{
|
||||||
|
sabun: string | null;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
user_name_eng: string | null;
|
||||||
|
user_name_cn: string | null;
|
||||||
|
dept_code: string | null;
|
||||||
|
dept_name: string | null;
|
||||||
|
position_code: string | null;
|
||||||
|
position_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
tel: string | null;
|
||||||
|
cell_phone: string | null;
|
||||||
|
user_type: string | null;
|
||||||
|
user_type_name: string | null;
|
||||||
|
partner_objid: string | null;
|
||||||
|
company_code: string | null;
|
||||||
|
locale: string | null;
|
||||||
|
photo: Buffer | null;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||||
|
dept_code, dept_name, position_code, position_name,
|
||||||
|
email, tel, cell_phone, user_type, user_type_name,
|
||||||
|
partner_objid, company_code, locale, photo
|
||||||
|
FROM user_info
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = result.length > 0 ? result[0] : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: getUserInfo() 전환 (권한 정보)
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
```typescript
|
||||||
|
const authInfo = await prisma.authority_sub_user.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
include: {
|
||||||
|
authority_master: {
|
||||||
|
select: { auth_name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const authNames = authInfo
|
||||||
|
.filter((auth: any) => auth.authority_master?.auth_name)
|
||||||
|
.map((auth: any) => auth.authority_master!.auth_name!)
|
||||||
|
.join(",");
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
```typescript
|
||||||
|
const authResult = await query<{ auth_name: string }>(
|
||||||
|
`SELECT am.auth_name
|
||||||
|
FROM authority_sub_user asu
|
||||||
|
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||||
|
WHERE asu.user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: getUserInfo() 전환 (회사 정보)
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
```typescript
|
||||||
|
const companyInfo = await prisma.company_mng.findFirst({
|
||||||
|
where: { company_code: userInfo.company_code || "ILSHIN" },
|
||||||
|
select: { company_name: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
```typescript
|
||||||
|
const companyResult = await query<{ company_name: string }>(
|
||||||
|
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[userInfo.company_code || "ILSHIN"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 완전 전환된 AuthService 코드
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { JwtUtils } from "../utils/jwtUtils";
|
||||||
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
|
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
/**
|
||||||
|
* 로그인 비밀번호 검증 (Raw Query 전환)
|
||||||
|
*/
|
||||||
|
static async loginPwdCheck(
|
||||||
|
userId: string,
|
||||||
|
password: string
|
||||||
|
): Promise<LoginResult> {
|
||||||
|
try {
|
||||||
|
// Raw Query로 사용자 비밀번호 조회
|
||||||
|
const result = await query<{ user_password: string }>(
|
||||||
|
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = result.length > 0 ? result[0] : null;
|
||||||
|
|
||||||
|
if (userInfo && userInfo.user_password) {
|
||||||
|
const dbPassword = userInfo.user_password;
|
||||||
|
|
||||||
|
logger.info(`로그인 시도: ${userId}`);
|
||||||
|
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||||
|
|
||||||
|
// 마스터 패스워드 체크
|
||||||
|
if (password === "qlalfqjsgh11") {
|
||||||
|
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||||
|
return { loginResult: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 검증
|
||||||
|
if (EncryptUtil.matches(password, dbPassword)) {
|
||||||
|
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||||
|
return { loginResult: true };
|
||||||
|
} else {
|
||||||
|
logger.warn(`비밀번호 불일치로 로그인 실패: ${userId}`);
|
||||||
|
return {
|
||||||
|
loginResult: false,
|
||||||
|
errorReason: "패스워드가 일치하지 않습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`사용자가 존재하지 않음: ${userId}`);
|
||||||
|
return {
|
||||||
|
loginResult: false,
|
||||||
|
errorReason: "사용자가 존재하지 않습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`로그인 검증 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
loginResult: false,
|
||||||
|
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 로그 기록 (이미 Raw Query 사용 - 유지)
|
||||||
|
*/
|
||||||
|
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO LOGIN_ACCESS_LOG(
|
||||||
|
LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
|
||||||
|
REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
|
||||||
|
) VALUES (
|
||||||
|
now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
logData.systemName,
|
||||||
|
logData.userId,
|
||||||
|
logData.loginResult,
|
||||||
|
logData.errorMessage || null,
|
||||||
|
logData.remoteAddr,
|
||||||
|
logData.recptnDt || null,
|
||||||
|
logData.recptnRsltDtl || null,
|
||||||
|
logData.recptnRslt || null,
|
||||||
|
logData.recptnRsltCd || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`로그인 로그 기록 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
// 로그 기록 실패는 로그인 프로세스를 중단하지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 정보 조회 (Raw Query 전환)
|
||||||
|
*/
|
||||||
|
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
||||||
|
try {
|
||||||
|
// 1. 사용자 기본 정보 조회
|
||||||
|
const userResult = await query<{
|
||||||
|
sabun: string | null;
|
||||||
|
user_id: string;
|
||||||
|
user_name: string;
|
||||||
|
user_name_eng: string | null;
|
||||||
|
user_name_cn: string | null;
|
||||||
|
dept_code: string | null;
|
||||||
|
dept_name: string | null;
|
||||||
|
position_code: string | null;
|
||||||
|
position_name: string | null;
|
||||||
|
email: string | null;
|
||||||
|
tel: string | null;
|
||||||
|
cell_phone: string | null;
|
||||||
|
user_type: string | null;
|
||||||
|
user_type_name: string | null;
|
||||||
|
partner_objid: string | null;
|
||||||
|
company_code: string | null;
|
||||||
|
locale: string | null;
|
||||||
|
photo: Buffer | null;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||||
|
dept_code, dept_name, position_code, position_name,
|
||||||
|
email, tel, cell_phone, user_type, user_type_name,
|
||||||
|
partner_objid, company_code, locale, photo
|
||||||
|
FROM user_info
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = userResult.length > 0 ? userResult[0] : null;
|
||||||
|
|
||||||
|
if (!userInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 권한 정보 조회 (JOIN으로 최적화)
|
||||||
|
const authResult = await query<{ auth_name: string }>(
|
||||||
|
`SELECT am.auth_name
|
||||||
|
FROM authority_sub_user asu
|
||||||
|
INNER JOIN authority_master am ON asu.auth_code = am.auth_code
|
||||||
|
WHERE asu.user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const authNames = authResult.map(row => row.auth_name).join(",");
|
||||||
|
|
||||||
|
// 3. 회사 정보 조회
|
||||||
|
const companyResult = await query<{ company_name: string }>(
|
||||||
|
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||||
|
[userInfo.company_code || "ILSHIN"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const companyInfo = companyResult.length > 0 ? companyResult[0] : null;
|
||||||
|
|
||||||
|
// PersonBean 형태로 변환
|
||||||
|
const personBean: PersonBean = {
|
||||||
|
userId: userInfo.user_id,
|
||||||
|
userName: userInfo.user_name || "",
|
||||||
|
userNameEng: userInfo.user_name_eng || undefined,
|
||||||
|
userNameCn: userInfo.user_name_cn || undefined,
|
||||||
|
deptCode: userInfo.dept_code || undefined,
|
||||||
|
deptName: userInfo.dept_name || undefined,
|
||||||
|
positionCode: userInfo.position_code || undefined,
|
||||||
|
positionName: userInfo.position_name || undefined,
|
||||||
|
email: userInfo.email || undefined,
|
||||||
|
tel: userInfo.tel || undefined,
|
||||||
|
cellPhone: userInfo.cell_phone || undefined,
|
||||||
|
userType: userInfo.user_type || undefined,
|
||||||
|
userTypeName: userInfo.user_type_name || undefined,
|
||||||
|
partnerObjid: userInfo.partner_objid || undefined,
|
||||||
|
authName: authNames || undefined,
|
||||||
|
companyCode: userInfo.company_code || "ILSHIN",
|
||||||
|
photo: userInfo.photo
|
||||||
|
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||||
|
: undefined,
|
||||||
|
locale: userInfo.locale || "KR",
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||||
|
return personBean;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰으로 사용자 정보 조회
|
||||||
|
*/
|
||||||
|
static async getUserInfoFromToken(token: string): Promise<PersonBean | null> {
|
||||||
|
try {
|
||||||
|
const userInfo = JwtUtils.verifyToken(token);
|
||||||
|
return userInfo;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`토큰에서 사용자 정보 조회 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그인 프로세스 전체 처리
|
||||||
|
*/
|
||||||
|
static async processLogin(
|
||||||
|
userId: string,
|
||||||
|
password: string,
|
||||||
|
remoteAddr: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
userInfo?: PersonBean;
|
||||||
|
token?: string;
|
||||||
|
errorReason?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// 1. 로그인 검증
|
||||||
|
const loginResult = await this.loginPwdCheck(userId, password);
|
||||||
|
|
||||||
|
// 2. 로그 기록
|
||||||
|
const logData: LoginLogData = {
|
||||||
|
systemName: "PMS",
|
||||||
|
userId: userId,
|
||||||
|
loginResult: loginResult.loginResult,
|
||||||
|
errorMessage: loginResult.errorReason,
|
||||||
|
remoteAddr: remoteAddr,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.insertLoginAccessLog(logData);
|
||||||
|
|
||||||
|
if (loginResult.loginResult) {
|
||||||
|
// 3. 사용자 정보 조회
|
||||||
|
const userInfo = await this.getUserInfo(userId);
|
||||||
|
if (!userInfo) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorReason: "사용자 정보를 조회할 수 없습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. JWT 토큰 생성
|
||||||
|
const token = JwtUtils.generateToken(userInfo);
|
||||||
|
|
||||||
|
logger.info(`로그인 성공: ${userId} (${remoteAddr})`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
userInfo,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`로그인 실패: ${userId} - ${loginResult.errorReason} (${remoteAddr})`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorReason: loginResult.errorReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`로그인 프로세스 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorReason: "로그인 처리 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 프로세스 처리
|
||||||
|
*/
|
||||||
|
static async processLogout(
|
||||||
|
userId: string,
|
||||||
|
remoteAddr: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 로그아웃 로그 기록
|
||||||
|
const logData: LoginLogData = {
|
||||||
|
systemName: "PMS",
|
||||||
|
userId: userId,
|
||||||
|
loginResult: false,
|
||||||
|
errorMessage: "로그아웃",
|
||||||
|
remoteAddr: remoteAddr,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.insertLoginAccessLog(logData);
|
||||||
|
logger.info(`로그아웃 완료: ${userId} (${remoteAddr})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`로그아웃 처리 중 오류 발생: ${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/tests/authService.test.ts
|
||||||
|
import { AuthService } from "../services/authService";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
describe("AuthService Raw Query 전환 테스트", () => {
|
||||||
|
describe("loginPwdCheck", () => {
|
||||||
|
test("존재하는 사용자 로그인 성공", async () => {
|
||||||
|
const result = await AuthService.loginPwdCheck("testuser", "testpass");
|
||||||
|
expect(result.loginResult).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("존재하지 않는 사용자 로그인 실패", async () => {
|
||||||
|
const result = await AuthService.loginPwdCheck("nonexistent", "password");
|
||||||
|
expect(result.loginResult).toBe(false);
|
||||||
|
expect(result.errorReason).toContain("존재하지 않습니다");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("잘못된 비밀번호 로그인 실패", async () => {
|
||||||
|
const result = await AuthService.loginPwdCheck("testuser", "wrongpass");
|
||||||
|
expect(result.loginResult).toBe(false);
|
||||||
|
expect(result.errorReason).toContain("일치하지 않습니다");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("마스터 패스워드 로그인 성공", async () => {
|
||||||
|
const result = await AuthService.loginPwdCheck("testuser", "qlalfqjsgh11");
|
||||||
|
expect(result.loginResult).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserInfo", () => {
|
||||||
|
test("사용자 정보 조회 성공", async () => {
|
||||||
|
const userInfo = await AuthService.getUserInfo("testuser");
|
||||||
|
expect(userInfo).not.toBeNull();
|
||||||
|
expect(userInfo?.userId).toBe("testuser");
|
||||||
|
expect(userInfo?.userName).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("권한 정보 조회 성공", async () => {
|
||||||
|
const userInfo = await AuthService.getUserInfo("testuser");
|
||||||
|
expect(userInfo?.authName).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("존재하지 않는 사용자 조회 실패", async () => {
|
||||||
|
const userInfo = await AuthService.getUserInfo("nonexistent");
|
||||||
|
expect(userInfo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processLogin", () => {
|
||||||
|
test("전체 로그인 프로세스 성공", async () => {
|
||||||
|
const result = await AuthService.processLogin(
|
||||||
|
"testuser",
|
||||||
|
"testpass",
|
||||||
|
"127.0.0.1"
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
expect(result.userInfo).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("로그인 실패 시 토큰 없음", async () => {
|
||||||
|
const result = await AuthService.processLogin(
|
||||||
|
"testuser",
|
||||||
|
"wrongpass",
|
||||||
|
"127.0.0.1"
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.token).toBeUndefined();
|
||||||
|
expect(result.errorReason).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertLoginAccessLog", () => {
|
||||||
|
test("로그인 로그 기록 성공", async () => {
|
||||||
|
await expect(
|
||||||
|
AuthService.insertLoginAccessLog({
|
||||||
|
systemName: "PMS",
|
||||||
|
userId: "testuser",
|
||||||
|
loginResult: true,
|
||||||
|
remoteAddr: "127.0.0.1",
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/tests/integration/auth.integration.test.ts
|
||||||
|
import request from "supertest";
|
||||||
|
import app from "../../app";
|
||||||
|
|
||||||
|
describe("인증 시스템 통합 테스트", () => {
|
||||||
|
let authToken: string;
|
||||||
|
|
||||||
|
test("POST /api/auth/login - 로그인 성공", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.send({
|
||||||
|
userId: "testuser",
|
||||||
|
password: "testpass",
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.token).toBeDefined();
|
||||||
|
expect(response.body.userInfo).toBeDefined();
|
||||||
|
|
||||||
|
authToken = response.body.token;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/auth/verify - 토큰 검증 성공", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get("/api/auth/verify")
|
||||||
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.valid).toBe(true);
|
||||||
|
expect(response.body.userInfo).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GET /api/admin/menu - 인증된 사용자 메뉴 조회", async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get("/api/admin/menu")
|
||||||
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("POST /api/auth/logout - 로그아웃 성공", async () => {
|
||||||
|
await request(app)
|
||||||
|
.post("/api/auth/logout")
|
||||||
|
.set("Authorization", `Bearer ${authToken}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### AuthService 전환
|
||||||
|
|
||||||
|
- [ ] import 문 변경 (`prisma` → `query`)
|
||||||
|
- [ ] `loginPwdCheck()` 메서드 전환
|
||||||
|
- [ ] Prisma findUnique → Raw Query SELECT
|
||||||
|
- [ ] 타입 정의 추가
|
||||||
|
- [ ] 에러 처리 확인
|
||||||
|
- [ ] `insertLoginAccessLog()` 메서드 확인
|
||||||
|
- [ ] 이미 Raw Query 사용 중 (유지)
|
||||||
|
- [ ] 파라미터 바인딩 확인
|
||||||
|
- [ ] `getUserInfo()` 메서드 전환
|
||||||
|
- [ ] 사용자 정보 조회 Raw Query 전환
|
||||||
|
- [ ] 권한 정보 조회 Raw Query 전환 (JOIN 최적화)
|
||||||
|
- [ ] 회사 정보 조회 Raw Query 전환
|
||||||
|
- [ ] PersonBean 타입 변환 로직 유지
|
||||||
|
- [ ] 모든 메서드 타입 안전성 확인
|
||||||
|
- [ ] 단위 테스트 작성 및 통과
|
||||||
|
|
||||||
|
### AdminService 확인
|
||||||
|
|
||||||
|
- [ ] 현재 코드 확인 (이미 Raw Query 사용 중)
|
||||||
|
- [ ] WITH RECURSIVE 쿼리 동작 확인
|
||||||
|
- [ ] 다국어 번역 로직 확인
|
||||||
|
|
||||||
|
### AdminController 전환
|
||||||
|
|
||||||
|
- [ ] Prisma 사용 현황 파악 (28개 호출)
|
||||||
|
- [ ] 각 API 엔드포인트별 전환 계획 수립
|
||||||
|
- [ ] Raw Query로 전환
|
||||||
|
- [ ] 통합 테스트 작성
|
||||||
|
|
||||||
|
### 통합 테스트
|
||||||
|
|
||||||
|
- [ ] 로그인 → 토큰 발급 테스트
|
||||||
|
- [ ] 토큰 검증 → API 호출 테스트
|
||||||
|
- [ ] 권한 확인 → 메뉴 조회 테스트
|
||||||
|
- [ ] 로그아웃 테스트
|
||||||
|
- [ ] 에러 케이스 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- ✅ AuthService의 모든 Prisma 호출 제거
|
||||||
|
- ✅ AdminService Raw Query 사용 확인
|
||||||
|
- ✅ AdminController Prisma 호출 제거
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
- ✅ 통합 테스트 통과
|
||||||
|
- ✅ 로그인 → 인증 → API 호출 플로우 정상 동작
|
||||||
|
- ✅ 성능 저하 없음 (기존 대비 ±10% 이내)
|
||||||
|
- ✅ 에러 처리 및 로깅 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 문서
|
||||||
|
|
||||||
|
- [Phase 1 완료 가이드](backend-node/PHASE1_USAGE_GUIDE.md)
|
||||||
|
- [DatabaseManager 사용법](backend-node/src/database/db.ts)
|
||||||
|
- [QueryBuilder 사용법](backend-node/src/utils/queryBuilder.ts)
|
||||||
|
- [전체 마이그레이션 계획](PRISMA_TO_RAW_QUERY_MIGRATION_PLAN.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**예상 소요 시간**: 2-3일
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
|
@ -0,0 +1,428 @@
|
||||||
|
# 🗂️ Phase 2.2: TableManagementService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
TableManagementService는 **33개의 Prisma 호출**이 있습니다. 대부분(약 26개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 33개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ----------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/tableManagementService.ts` |
|
||||||
|
| 파일 크기 | 3,178 라인 |
|
||||||
|
| Prisma 호출 | 33개 ($queryRaw: 26개, ORM: 7개) |
|
||||||
|
| **현재 진행률** | **0/33 (0%)** ⏳ **전환 필요** |
|
||||||
|
| **전환 필요** | **33개 모두 전환 필요** (SQL은 이미 작성되어 있음) |
|
||||||
|
| 복잡도 | 중간 (SQL 작성은 완료, `query()` 함수로 교체만 필요) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 2.2) |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ **33개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||||
|
- 26개 `$queryRaw` → `query()` 또는 `queryOne()`
|
||||||
|
- 7개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||||
|
- 1개 `$transaction` → `transaction()`
|
||||||
|
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (26개)
|
||||||
|
|
||||||
|
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||||
|
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존
|
||||||
|
await prisma.$queryRaw`SELECT ...`;
|
||||||
|
await prisma.$queryRawUnsafe(sqlString, ...params);
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
await query(`SELECT ...`);
|
||||||
|
await query(sqlString, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ORM 메서드 사용 (7개)
|
||||||
|
|
||||||
|
**현재 상태**: Prisma ORM 메서드 사용
|
||||||
|
**전환 작업**: SQL 작성 필요
|
||||||
|
|
||||||
|
#### 1. table_labels 관리 (2개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 254: 테이블 라벨 UPSERT
|
||||||
|
await prisma.table_labels.upsert({
|
||||||
|
where: { table_name: tableName },
|
||||||
|
update: {},
|
||||||
|
create: { table_name, table_label, description }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 437: 테이블 라벨 조회
|
||||||
|
await prisma.table_labels.findUnique({
|
||||||
|
where: { table_name: tableName },
|
||||||
|
select: { table_name, table_label, description, ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. column_labels 관리 (5개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 323: 컬럼 라벨 UPSERT
|
||||||
|
await prisma.column_labels.upsert({
|
||||||
|
where: {
|
||||||
|
table_name_column_name: {
|
||||||
|
table_name: tableName,
|
||||||
|
column_name: columnName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update: { column_label, input_type, ... },
|
||||||
|
create: { table_name, column_name, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 481: 컬럼 라벨 조회
|
||||||
|
await prisma.column_labels.findUnique({
|
||||||
|
where: {
|
||||||
|
table_name_column_name: {
|
||||||
|
table_name: tableName,
|
||||||
|
column_name: columnName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: { id, table_name, column_name, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 567: 컬럼 존재 확인
|
||||||
|
await prisma.column_labels.findFirst({
|
||||||
|
where: { table_name, column_name }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 586: 컬럼 라벨 업데이트
|
||||||
|
await prisma.column_labels.update({
|
||||||
|
where: { id: existingColumn.id },
|
||||||
|
data: { web_type, detail_settings, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 610: 컬럼 라벨 생성
|
||||||
|
await prisma.column_labels.create({
|
||||||
|
data: { table_name, column_name, web_type, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 1003: 파일 타입 컬럼 조회
|
||||||
|
await prisma.column_labels.findMany({
|
||||||
|
where: { table_name, web_type: 'file' },
|
||||||
|
select: { column_name }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 1382: 컬럼 웹타입 정보 조회
|
||||||
|
await prisma.column_labels.findFirst({
|
||||||
|
where: { table_name, column_name },
|
||||||
|
select: { web_type, code_category, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 2690: 컬럼 라벨 UPSERT (복제)
|
||||||
|
await prisma.column_labels.upsert({
|
||||||
|
where: {
|
||||||
|
table_name_column_name: { table_name, column_name }
|
||||||
|
},
|
||||||
|
update: { column_label, web_type, ... },
|
||||||
|
create: { table_name, column_name, ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. attach_file_info 관리 (2개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 914: 파일 정보 조회
|
||||||
|
await prisma.attach_file_info.findMany({
|
||||||
|
where: { target_objid, doc_type, status: 'ACTIVE' },
|
||||||
|
select: { objid, real_file_name, file_size, ... },
|
||||||
|
orderBy: { regdate: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 959: 파일 경로로 파일 정보 조회
|
||||||
|
await prisma.attach_file_info.findFirst({
|
||||||
|
where: { file_path, status: 'ACTIVE' },
|
||||||
|
select: { objid, real_file_name, ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 트랜잭션 (1개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 391: 전체 컬럼 설정 일괄 업데이트
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await this.insertTableIfNotExists(tableName);
|
||||||
|
for (const columnSetting of columnSettings) {
|
||||||
|
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: table_labels UPSERT 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.table_labels.upsert({
|
||||||
|
where: { table_name: tableName },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
table_name: tableName,
|
||||||
|
table_label: tableName,
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name) DO NOTHING`,
|
||||||
|
[tableName, tableName, ""]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: column_labels UPSERT 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.column_labels.upsert({
|
||||||
|
where: {
|
||||||
|
table_name_column_name: {
|
||||||
|
table_name: tableName,
|
||||||
|
column_name: columnName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
column_label: settings.columnLabel,
|
||||||
|
input_type: settings.inputType,
|
||||||
|
detail_settings: settings.detailSettings,
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
table_name: tableName,
|
||||||
|
column_name: columnName,
|
||||||
|
column_label: settings.columnLabel,
|
||||||
|
input_type: settings.inputType,
|
||||||
|
detail_settings: settings.detailSettings,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await query(
|
||||||
|
`INSERT INTO column_labels (
|
||||||
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
|
code_category, code_value, reference_table, reference_column,
|
||||||
|
display_column, display_order, is_visible, created_date, updated_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name, column_name)
|
||||||
|
DO UPDATE SET
|
||||||
|
column_label = EXCLUDED.column_label,
|
||||||
|
input_type = EXCLUDED.input_type,
|
||||||
|
detail_settings = EXCLUDED.detail_settings,
|
||||||
|
code_category = EXCLUDED.code_category,
|
||||||
|
code_value = EXCLUDED.code_value,
|
||||||
|
reference_table = EXCLUDED.reference_table,
|
||||||
|
reference_column = EXCLUDED.reference_column,
|
||||||
|
display_column = EXCLUDED.display_column,
|
||||||
|
display_order = EXCLUDED.display_order,
|
||||||
|
is_visible = EXCLUDED.is_visible,
|
||||||
|
updated_date = NOW()`,
|
||||||
|
[
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
settings.columnLabel,
|
||||||
|
settings.inputType,
|
||||||
|
settings.detailSettings,
|
||||||
|
settings.codeCategory,
|
||||||
|
settings.codeValue,
|
||||||
|
settings.referenceTable,
|
||||||
|
settings.referenceColumn,
|
||||||
|
settings.displayColumn,
|
||||||
|
settings.displayOrder || 0,
|
||||||
|
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 트랜잭션 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await this.insertTableIfNotExists(tableName);
|
||||||
|
for (const columnSetting of columnSettings) {
|
||||||
|
await this.updateColumnSettings(tableName, columnName, columnSetting);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { transaction } from "../database/db";
|
||||||
|
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 테이블 라벨 자동 추가
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, NOW(), NOW())
|
||||||
|
ON CONFLICT (table_name) DO NOTHING`,
|
||||||
|
[tableName, tableName, ""]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 각 컬럼 설정 업데이트
|
||||||
|
for (const columnSetting of columnSettings) {
|
||||||
|
const columnName = columnSetting.columnName;
|
||||||
|
if (columnName) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO column_labels (...)
|
||||||
|
VALUES (...)
|
||||||
|
ON CONFLICT (table_name, column_name) DO UPDATE SET ...`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (10개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("TableManagementService Raw Query 전환 테스트", () => {
|
||||||
|
describe("insertTableIfNotExists", () => {
|
||||||
|
test("테이블 라벨 UPSERT 성공", async () => { ... });
|
||||||
|
test("중복 테이블 처리", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateColumnSettings", () => {
|
||||||
|
test("컬럼 설정 UPSERT 성공", async () => { ... });
|
||||||
|
test("기존 컬럼 업데이트", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTableLabels", () => {
|
||||||
|
test("테이블 라벨 조회 성공", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getColumnLabels", () => {
|
||||||
|
test("컬럼 라벨 조회 성공", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateAllColumnSettings", () => {
|
||||||
|
test("일괄 업데이트 성공 (트랜잭션)", async () => { ... });
|
||||||
|
test("부분 실패 시 롤백", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getFileInfoByColumnAndTarget", () => {
|
||||||
|
test("파일 정보 조회 성공", async () => { ... });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (5개 시나리오)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("테이블 관리 통합 테스트", () => {
|
||||||
|
test("테이블 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||||
|
test("컬럼 라벨 생성 → 조회 → 수정", async () => { ... });
|
||||||
|
test("컬럼 일괄 설정 업데이트", async () => { ... });
|
||||||
|
test("파일 정보 조회 및 보강", async () => { ... });
|
||||||
|
test("트랜잭션 롤백 테스트", async () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 1단계: table_labels 전환 (2개 함수) ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] `insertTableIfNotExists()` - UPSERT
|
||||||
|
- [ ] `getTableLabels()` - 조회
|
||||||
|
|
||||||
|
### 2단계: column_labels 전환 (5개 함수) ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] `updateColumnSettings()` - UPSERT
|
||||||
|
- [ ] `getColumnLabels()` - 조회
|
||||||
|
- [ ] `updateColumnWebType()` - findFirst + update/create
|
||||||
|
- [ ] `getColumnWebTypeInfo()` - findFirst
|
||||||
|
- [ ] `updateColumnLabel()` - UPSERT (복제)
|
||||||
|
|
||||||
|
### 3단계: attach_file_info 전환 (2개 함수) ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] `getFileInfoByColumnAndTarget()` - findMany
|
||||||
|
- [ ] `getFileInfoByPath()` - findFirst
|
||||||
|
|
||||||
|
### 4단계: 트랜잭션 전환 (1개 함수) ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] `updateAllColumnSettings()` - 트랜잭션
|
||||||
|
|
||||||
|
### 5단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (10개)
|
||||||
|
- [ ] 통합 테스트 작성 (5개 시나리오)
|
||||||
|
- [ ] Prisma import 완전 제거 확인
|
||||||
|
- [ ] 성능 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [ ] **33개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||||
|
- [ ] 26개 `$queryRaw` → `query()` 함수로 교체
|
||||||
|
- [ ] 7개 ORM 메서드 → `query()` 함수로 전환 (SQL 작성)
|
||||||
|
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- [ ] **트랜잭션 정상 동작 확인**
|
||||||
|
- [ ] **에러 처리 및 롤백 정상 동작**
|
||||||
|
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||||
|
- [ ] **모든 통합 테스트 작성 완료 (5개 시나리오)**
|
||||||
|
- [ ] **`import prisma` 완전 제거 및 `import { query, transaction } from "../database/db"` 사용**
|
||||||
|
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### SQL은 이미 대부분 작성되어 있음
|
||||||
|
|
||||||
|
이 서비스는 이미 79%가 `$queryRaw`를 사용하고 있어, **SQL 작성은 완료**되었습니다:
|
||||||
|
|
||||||
|
- ✅ `information_schema` 조회: SQL 작성 완료 (`$queryRaw` 사용 중)
|
||||||
|
- ✅ 동적 테이블 쿼리: SQL 작성 완료 (`$queryRawUnsafe` 사용 중)
|
||||||
|
- ✅ DDL 실행: SQL 작성 완료 (`$executeRaw` 사용 중)
|
||||||
|
- ⏳ **전환 작업**: `prisma.$queryRaw` → `query()` 함수로 **단순 교체만 필요**
|
||||||
|
- ⏳ CRUD 작업: 7개만 SQL 새로 작성 필요
|
||||||
|
|
||||||
|
### UPSERT 패턴 중요
|
||||||
|
|
||||||
|
대부분의 전환이 UPSERT 패턴이므로 PostgreSQL의 `ON CONFLICT` 구문을 활용합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**예상 소요 시간**: 1-1.5일 (SQL은 79% 작성 완료, 함수 교체 작업 필요)
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟡 중간 (Phase 2.2)
|
||||||
|
**상태**: ⏳ **진행 예정**
|
||||||
|
**특이사항**: SQL은 대부분 작성되어 있어 `prisma.$queryRaw` → `query()` 단순 교체 작업이 주요 작업
|
||||||
|
|
@ -0,0 +1,736 @@
|
||||||
|
# 📊 Phase 2.3: DataflowService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DataflowService는 **31개의 Prisma 호출**이 있는 핵심 서비스입니다. 테이블 간 관계 관리, 데이터플로우 다이어그램, 데이터 연결 브리지 등 복잡한 기능을 포함합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ---------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/dataflowService.ts` |
|
||||||
|
| 파일 크기 | 1,170+ 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **31/31 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 매우 높음 (트랜잭션 + 복잡한 관계 관리) |
|
||||||
|
| 우선순위 | 🔴 최우선 (Phase 2.3) |
|
||||||
|
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ 31개 Prisma 호출을 모두 Raw Query로 전환
|
||||||
|
- ✅ 트랜잭션 처리 정상 동작 확인
|
||||||
|
- ✅ 에러 처리 및 롤백 정상 동작
|
||||||
|
- ✅ 모든 단위 테스트 통과 (20개 이상)
|
||||||
|
- ✅ 통합 테스트 작성 완료
|
||||||
|
- ✅ Prisma import 완전 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 1. 테이블 관계 관리 (Table Relationships) - 22개
|
||||||
|
|
||||||
|
#### 1.1 관계 생성 (3개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 48: 최대 diagram_id 조회
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: { company_code },
|
||||||
|
orderBy: { diagram_id: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 64: 중복 관계 확인
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: { diagram_id, source_table, target_table, relationship_type }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 83: 새 관계 생성
|
||||||
|
await prisma.table_relationships.create({
|
||||||
|
data: { diagram_id, source_table, target_table, ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 관계 조회 (6개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 128: 관계 목록 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
orderBy: { created_at: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 164: 단일 관계 조회
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: whereCondition
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 287: 회사별 관계 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: { company_code, is_active: 'Y' },
|
||||||
|
orderBy: { diagram_id: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 326: 테이블별 관계 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
orderBy: { relationship_type: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 784: diagram_id별 관계 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
select: { diagram_id, diagram_name, source_table, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 883: 회사 코드로 전체 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: { company_code, is_active: 'Y' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 통계 조회 (3개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 362: 전체 관계 수
|
||||||
|
await prisma.table_relationships.count({
|
||||||
|
where: whereCondition,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 367: 관계 타입별 통계
|
||||||
|
await prisma.table_relationships.groupBy({
|
||||||
|
by: ["relationship_type"],
|
||||||
|
where: whereCondition,
|
||||||
|
_count: { relationship_id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 376: 연결 타입별 통계
|
||||||
|
await prisma.table_relationships.groupBy({
|
||||||
|
by: ["connection_type"],
|
||||||
|
where: whereCondition,
|
||||||
|
_count: { relationship_id: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4 관계 수정/삭제 (5개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 209: 관계 수정
|
||||||
|
await prisma.table_relationships.update({
|
||||||
|
where: { relationship_id },
|
||||||
|
data: { source_table, target_table, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 248: 소프트 삭제
|
||||||
|
await prisma.table_relationships.update({
|
||||||
|
where: { relationship_id },
|
||||||
|
data: { is_active: 'N', updated_at: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 936: 중복 diagram_name 확인
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: { company_code, diagram_name, is_active: 'Y' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 953: 최대 diagram_id 조회 (복사용)
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: { company_code },
|
||||||
|
orderBy: { diagram_id: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 1015: 관계도 완전 삭제
|
||||||
|
await prisma.table_relationships.deleteMany({
|
||||||
|
where: { company_code, diagram_id, is_active: 'Y' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.5 복잡한 조회 (5개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 919: 원본 관계도 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: { company_code, diagram_id: sourceDiagramId, is_active: "Y" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 1046: diagram_id로 모든 관계 조회
|
||||||
|
await prisma.table_relationships.findMany({
|
||||||
|
where: { diagram_id, is_active: "Y" },
|
||||||
|
orderBy: { created_at: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 1085: 특정 relationship_id의 diagram_id 찾기
|
||||||
|
await prisma.table_relationships.findFirst({
|
||||||
|
where: { relationship_id, company_code },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 데이터 연결 브리지 (Data Relationship Bridge) - 8개
|
||||||
|
|
||||||
|
#### 2.1 브리지 생성/수정 (4개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 425: 브리지 생성
|
||||||
|
await prisma.data_relationship_bridge.create({
|
||||||
|
data: {
|
||||||
|
relationship_id,
|
||||||
|
source_record_id,
|
||||||
|
target_record_id,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 554: 브리지 수정
|
||||||
|
await prisma.data_relationship_bridge.update({
|
||||||
|
where: whereCondition,
|
||||||
|
data: { target_record_id, ... }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 595: 브리지 소프트 삭제
|
||||||
|
await prisma.data_relationship_bridge.update({
|
||||||
|
where: whereCondition,
|
||||||
|
data: { is_active: 'N', updated_at: new Date() }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 637: 브리지 일괄 삭제
|
||||||
|
await prisma.data_relationship_bridge.updateMany({
|
||||||
|
where: whereCondition,
|
||||||
|
data: { is_active: 'N', updated_at: new Date() }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 브리지 조회 (4개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 471: relationship_id로 브리지 조회
|
||||||
|
await prisma.data_relationship_bridge.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 512: 레코드별 브리지 조회
|
||||||
|
await prisma.data_relationship_bridge.findMany({
|
||||||
|
where: whereCondition,
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Raw Query 사용 (이미 있음) - 1개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 673: 테이블 존재 확인
|
||||||
|
await prisma.$queryRaw`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = ${tableName}
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 트랜잭션 사용 - 1개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 968: 관계도 복사 트랜잭션
|
||||||
|
await prisma.$transaction(
|
||||||
|
originalRelationships.map((rel) =>
|
||||||
|
prisma.table_relationships.create({
|
||||||
|
data: {
|
||||||
|
diagram_id: newDiagramId,
|
||||||
|
company_code: companyCode,
|
||||||
|
source_table: rel.source_table,
|
||||||
|
target_table: rel.target_table,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 전환 전략
|
||||||
|
|
||||||
|
### 전략 1: 단계적 전환
|
||||||
|
|
||||||
|
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||||
|
2. **2단계**: 복잡한 조회 전환 (groupBy, count, 조건부 조회)
|
||||||
|
3. **3단계**: 트랜잭션 전환
|
||||||
|
4. **4단계**: Raw Query 개선
|
||||||
|
|
||||||
|
### 전략 2: 함수별 전환 우선순위
|
||||||
|
|
||||||
|
#### 🔴 최우선 (기본 CRUD)
|
||||||
|
|
||||||
|
- `createRelationship()` - Line 83
|
||||||
|
- `getRelationships()` - Line 128
|
||||||
|
- `getRelationshipById()` - Line 164
|
||||||
|
- `updateRelationship()` - Line 209
|
||||||
|
- `deleteRelationship()` - Line 248
|
||||||
|
|
||||||
|
#### 🟡 2순위 (브리지 관리)
|
||||||
|
|
||||||
|
- `createDataLink()` - Line 425
|
||||||
|
- `getLinkedData()` - Line 471
|
||||||
|
- `getLinkedDataByRecord()` - Line 512
|
||||||
|
- `updateDataLink()` - Line 554
|
||||||
|
- `deleteDataLink()` - Line 595
|
||||||
|
|
||||||
|
#### 🟢 3순위 (통계 & 조회)
|
||||||
|
|
||||||
|
- `getRelationshipStats()` - Line 362-376
|
||||||
|
- `getAllRelationshipsByCompany()` - Line 287
|
||||||
|
- `getRelationshipsByTable()` - Line 326
|
||||||
|
- `getDiagrams()` - Line 784
|
||||||
|
|
||||||
|
#### 🔵 4순위 (복잡한 기능)
|
||||||
|
|
||||||
|
- `copyDiagram()` - Line 968 (트랜잭션)
|
||||||
|
- `deleteDiagram()` - Line 1015
|
||||||
|
- `getRelationshipsForDiagram()` - Line 1046
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: createRelationship() 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 48: 최대 diagram_id 조회
|
||||||
|
const maxDiagramId = await prisma.table_relationships.findFirst({
|
||||||
|
where: { company_code: data.companyCode },
|
||||||
|
orderBy: { diagram_id: 'desc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 64: 중복 관계 확인
|
||||||
|
const existingRelationship = await prisma.table_relationships.findFirst({
|
||||||
|
where: {
|
||||||
|
diagram_id: diagramId,
|
||||||
|
source_table: data.sourceTable,
|
||||||
|
target_table: data.targetTable,
|
||||||
|
relationship_type: data.relationshipType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 83: 새 관계 생성
|
||||||
|
const relationship = await prisma.table_relationships.create({
|
||||||
|
data: {
|
||||||
|
diagram_id: diagramId,
|
||||||
|
company_code: data.companyCode,
|
||||||
|
diagram_name: data.diagramName,
|
||||||
|
source_table: data.sourceTable,
|
||||||
|
target_table: data.targetTable,
|
||||||
|
relationship_type: data.relationshipType,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
// 최대 diagram_id 조회
|
||||||
|
const maxDiagramResult = await query<{ diagram_id: number }>(
|
||||||
|
`SELECT diagram_id FROM table_relationships
|
||||||
|
WHERE company_code = $1
|
||||||
|
ORDER BY diagram_id DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[data.companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const diagramId =
|
||||||
|
data.diagramId ||
|
||||||
|
(maxDiagramResult.length > 0 ? maxDiagramResult[0].diagram_id + 1 : 1);
|
||||||
|
|
||||||
|
// 중복 관계 확인
|
||||||
|
const existingResult = await query<{ relationship_id: number }>(
|
||||||
|
`SELECT relationship_id FROM table_relationships
|
||||||
|
WHERE diagram_id = $1
|
||||||
|
AND source_table = $2
|
||||||
|
AND target_table = $3
|
||||||
|
AND relationship_type = $4
|
||||||
|
LIMIT 1`,
|
||||||
|
[diagramId, data.sourceTable, data.targetTable, data.relationshipType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.length > 0) {
|
||||||
|
throw new Error("이미 존재하는 관계입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새 관계 생성
|
||||||
|
const [relationship] = await query<TableRelationship>(
|
||||||
|
`INSERT INTO table_relationships (
|
||||||
|
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||||
|
relationship_type, connection_type, source_column, target_column,
|
||||||
|
is_active, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
diagramId,
|
||||||
|
data.companyCode,
|
||||||
|
data.diagramName,
|
||||||
|
data.sourceTable,
|
||||||
|
data.targetTable,
|
||||||
|
data.relationshipType,
|
||||||
|
data.connectionType,
|
||||||
|
data.sourceColumn,
|
||||||
|
data.targetColumn,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: getRelationshipStats() 전환 (통계 조회)
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 362: 전체 관계 수
|
||||||
|
const totalCount = await prisma.table_relationships.count({
|
||||||
|
where: whereCondition,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 367: 관계 타입별 통계
|
||||||
|
const relationshipTypeStats = await prisma.table_relationships.groupBy({
|
||||||
|
by: ["relationship_type"],
|
||||||
|
where: whereCondition,
|
||||||
|
_count: { relationship_id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 376: 연결 타입별 통계
|
||||||
|
const connectionTypeStats = await prisma.table_relationships.groupBy({
|
||||||
|
by: ["connection_type"],
|
||||||
|
where: whereCondition,
|
||||||
|
_count: { relationship_id: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WHERE 조건 동적 생성
|
||||||
|
const whereParams: any[] = [];
|
||||||
|
let whereSQL = "";
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode) {
|
||||||
|
whereSQL += `WHERE company_code = $${paramIndex}`;
|
||||||
|
whereParams.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
whereSQL += ` AND is_active = $${paramIndex}`;
|
||||||
|
whereParams.push(isActive ? "Y" : "N");
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전체 관계 수
|
||||||
|
const [totalResult] = await query<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM table_relationships ${whereSQL}`,
|
||||||
|
whereParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalCount = totalResult?.count || 0;
|
||||||
|
|
||||||
|
// 관계 타입별 통계
|
||||||
|
const relationshipTypeStats = await query<{
|
||||||
|
relationship_type: string;
|
||||||
|
count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT relationship_type, COUNT(*) as count
|
||||||
|
FROM table_relationships ${whereSQL}
|
||||||
|
GROUP BY relationship_type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
whereParams
|
||||||
|
);
|
||||||
|
|
||||||
|
// 연결 타입별 통계
|
||||||
|
const connectionTypeStats = await query<{
|
||||||
|
connection_type: string;
|
||||||
|
count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT connection_type, COUNT(*) as count
|
||||||
|
FROM table_relationships ${whereSQL}
|
||||||
|
GROUP BY connection_type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
whereParams
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: copyDiagram() 트랜잭션 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 968: 트랜잭션으로 모든 관계 복사
|
||||||
|
const copiedRelationships = await prisma.$transaction(
|
||||||
|
originalRelationships.map((rel) =>
|
||||||
|
prisma.table_relationships.create({
|
||||||
|
data: {
|
||||||
|
diagram_id: newDiagramId,
|
||||||
|
company_code: companyCode,
|
||||||
|
diagram_name: newDiagramName,
|
||||||
|
source_table: rel.source_table,
|
||||||
|
target_table: rel.target_table,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { transaction } from "../database/db";
|
||||||
|
|
||||||
|
const copiedRelationships = await transaction(async (client) => {
|
||||||
|
const results: TableRelationship[] = [];
|
||||||
|
|
||||||
|
for (const rel of originalRelationships) {
|
||||||
|
const [copiedRel] = await client.query<TableRelationship>(
|
||||||
|
`INSERT INTO table_relationships (
|
||||||
|
diagram_id, company_code, diagram_name, source_table, target_table,
|
||||||
|
relationship_type, connection_type, source_column, target_column,
|
||||||
|
is_active, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y', NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
newDiagramId,
|
||||||
|
companyCode,
|
||||||
|
newDiagramName,
|
||||||
|
rel.source_table,
|
||||||
|
rel.target_table,
|
||||||
|
rel.relationship_type,
|
||||||
|
rel.connection_type,
|
||||||
|
rel.source_column,
|
||||||
|
rel.target_column,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push(copiedRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (20개 이상)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('DataflowService Raw Query 전환 테스트', () => {
|
||||||
|
describe('createRelationship', () => {
|
||||||
|
test('관계 생성 성공', async () => { ... });
|
||||||
|
test('중복 관계 에러', async () => { ... });
|
||||||
|
test('diagram_id 자동 생성', async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelationships', () => {
|
||||||
|
test('전체 관계 조회 성공', async () => { ... });
|
||||||
|
test('회사별 필터링', async () => { ... });
|
||||||
|
test('diagram_id별 필터링', async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRelationshipStats', () => {
|
||||||
|
test('통계 조회 성공', async () => { ... });
|
||||||
|
test('관계 타입별 그룹화', async () => { ... });
|
||||||
|
test('연결 타입별 그룹화', async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyDiagram', () => {
|
||||||
|
test('관계도 복사 성공 (트랜잭션)', async () => { ... });
|
||||||
|
test('diagram_name 중복 에러', async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDataLink', () => {
|
||||||
|
test('데이터 연결 생성 성공', async () => { ... });
|
||||||
|
test('브리지 레코드 저장', async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLinkedData', () => {
|
||||||
|
test('연결된 데이터 조회', async () => { ... });
|
||||||
|
test('relationship_id별 필터링', async () => { ... });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (7개 시나리오)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('Dataflow 관리 통합 테스트', () => {
|
||||||
|
test('관계 생명주기 (생성 → 조회 → 수정 → 삭제)', async () => { ... });
|
||||||
|
test('관계도 복사 및 검증', async () => { ... });
|
||||||
|
test('데이터 연결 브리지 생성 및 조회', async () => { ... });
|
||||||
|
test('통계 정보 조회', async () => { ... });
|
||||||
|
test('테이블별 관계 조회', async () => { ... });
|
||||||
|
test('diagram_id별 관계 조회', async () => { ... });
|
||||||
|
test('관계도 완전 삭제', async () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `createTableRelationship()` - 관계 생성
|
||||||
|
- [x] `getTableRelationships()` - 관계 목록 조회
|
||||||
|
- [x] `getTableRelationship()` - 단일 관계 조회
|
||||||
|
- [x] `updateTableRelationship()` - 관계 수정
|
||||||
|
- [x] `deleteTableRelationship()` - 관계 삭제 (소프트)
|
||||||
|
- [x] `getRelationshipsByTable()` - 테이블별 조회
|
||||||
|
- [x] `getRelationshipsByConnectionType()` - 연결타입별 조회
|
||||||
|
- [x] `getDataFlowDiagrams()` - diagram_id별 그룹 조회
|
||||||
|
|
||||||
|
### 2단계: 브리지 관리 (6개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `createDataLink()` - 데이터 연결 생성
|
||||||
|
- [x] `getLinkedDataByRelationship()` - 관계별 연결 데이터 조회
|
||||||
|
- [x] `getLinkedDataByTable()` - 테이블별 연결 데이터 조회
|
||||||
|
- [x] `updateDataLink()` - 연결 수정
|
||||||
|
- [x] `deleteDataLink()` - 연결 삭제 (소프트)
|
||||||
|
- [x] `deleteAllLinkedDataByRelationship()` - 관계별 모든 연결 삭제
|
||||||
|
|
||||||
|
### 3단계: 통계 & 복잡한 조회 (4개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `getRelationshipStats()` - 통계 조회
|
||||||
|
- [x] count 쿼리 전환
|
||||||
|
- [x] groupBy 쿼리 전환 (관계 타입별)
|
||||||
|
- [x] groupBy 쿼리 전환 (연결 타입별)
|
||||||
|
- [x] `getTableData()` - 테이블 데이터 조회 (페이징)
|
||||||
|
- [x] `getDiagramRelationships()` - 관계도 관계 조회
|
||||||
|
- [x] `getDiagramRelationshipsByDiagramId()` - diagram_id별 관계 조회
|
||||||
|
|
||||||
|
### 4단계: 복잡한 기능 (3개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `copyDiagram()` - 관계도 복사 (트랜잭션)
|
||||||
|
- [x] `deleteDiagram()` - 관계도 완전 삭제
|
||||||
|
- [x] `getDiagramRelationshipsByRelationshipId()` - relationship_id로 조회
|
||||||
|
|
||||||
|
### 5단계: 테스트 & 검증 ⏳ **진행 필요**
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (20개 이상)
|
||||||
|
- createTableRelationship, updateTableRelationship, deleteTableRelationship
|
||||||
|
- getTableRelationships, getTableRelationship
|
||||||
|
- createDataLink, getLinkedDataByRelationship
|
||||||
|
- getRelationshipStats
|
||||||
|
- copyDiagram
|
||||||
|
- [ ] 통합 테스트 작성 (7개 시나리오)
|
||||||
|
- 관계 생명주기 테스트
|
||||||
|
- 관계도 복사 테스트
|
||||||
|
- 데이터 브리지 테스트
|
||||||
|
- 통계 조회 테스트
|
||||||
|
- [x] Prisma import 완전 제거 확인
|
||||||
|
- [ ] 성능 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [x] **31개 Prisma 호출 모두 Raw Query로 전환 완료** ✅
|
||||||
|
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||||
|
- [x] **트랜잭션 정상 동작 확인** ✅
|
||||||
|
- [x] **에러 처리 및 롤백 정상 동작** ✅
|
||||||
|
- [ ] **모든 단위 테스트 통과 (20개 이상)** ⏳
|
||||||
|
- [ ] **모든 통합 테스트 작성 완료 (7개 시나리오)** ⏳
|
||||||
|
- [x] **Prisma import 완전 제거** ✅
|
||||||
|
- [ ] **성능 저하 없음 (기존 대비 ±10% 이내)** ⏳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 주요 기술적 도전 과제
|
||||||
|
|
||||||
|
### 1. groupBy 쿼리 전환
|
||||||
|
|
||||||
|
**문제**: Prisma의 `groupBy`를 Raw Query로 전환
|
||||||
|
**해결**: PostgreSQL의 `GROUP BY` 및 집계 함수 사용
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT relationship_type, COUNT(*) as count
|
||||||
|
FROM table_relationships
|
||||||
|
WHERE company_code = $1 AND is_active = 'Y'
|
||||||
|
GROUP BY relationship_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 트랜잭션 배열 처리
|
||||||
|
|
||||||
|
**문제**: Prisma의 `$transaction([...])` 배열 방식을 Raw Query로 전환
|
||||||
|
**해결**: `transaction` 함수 내에서 순차 실행
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await transaction(async (client) => {
|
||||||
|
const results = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const result = await client.query(...);
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 동적 WHERE 조건 생성
|
||||||
|
|
||||||
|
**문제**: 다양한 필터 조건을 동적으로 구성
|
||||||
|
**해결**: 조건부 파라미터 인덱스 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const whereParams: any[] = [];
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
whereParams.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagramId) {
|
||||||
|
whereConditions.push(`diagram_id = $${paramIndex++}`);
|
||||||
|
whereParams.push(diagramId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSQL =
|
||||||
|
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 전환 완료 요약
|
||||||
|
|
||||||
|
### ✅ 성공적으로 전환된 항목
|
||||||
|
|
||||||
|
1. **기본 CRUD (8개)**: 모든 테이블 관계 CRUD 작업을 Raw Query로 전환
|
||||||
|
2. **브리지 관리 (6개)**: 데이터 연결 브리지의 모든 작업 전환
|
||||||
|
3. **통계 & 조회 (4개)**: COUNT, GROUP BY 등 복잡한 통계 쿼리 전환
|
||||||
|
4. **복잡한 기능 (3개)**: 트랜잭션 기반 관계도 복사 등 고급 기능 전환
|
||||||
|
|
||||||
|
### 🔧 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **트랜잭션 처리**: `transaction()` 함수 내에서 `client.query().rows` 사용
|
||||||
|
2. **동적 WHERE 조건**: 파라미터 인덱스를 동적으로 관리하여 유연한 쿼리 생성
|
||||||
|
3. **GROUP BY 전환**: Prisma의 `groupBy`를 PostgreSQL의 네이티브 GROUP BY로 전환
|
||||||
|
4. **타입 안전성**: 모든 쿼리 결과에 TypeScript 타입 지정
|
||||||
|
|
||||||
|
### 📈 다음 단계
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 및 실행
|
||||||
|
- [ ] 통합 테스트 시나리오 구현
|
||||||
|
- [ ] 성능 벤치마크 테스트
|
||||||
|
- [ ] 프로덕션 배포 준비
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**완료일**: 2025-10-01
|
||||||
|
**소요 시간**: 1일
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🔴 최우선 (Phase 2.3)
|
||||||
|
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
# 📝 Phase 2.4: DynamicFormService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DynamicFormService는 **13개의 Prisma 호출**이 있습니다. 대부분(약 11개)은 `$queryRaw`를 사용하고 있어 SQL은 이미 작성되어 있지만, **Prisma 클라이언트를 완전히 제거하려면 13개 모두를 `db.ts`의 `query` 함수로 교체**해야 합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/dynamicFormService.ts` |
|
||||||
|
| 파일 크기 | 1,213 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **13/13 (100%)** ✅ **완료** |
|
||||||
|
| **전환 상태** | **Raw Query로 전환 완료** |
|
||||||
|
| 복잡도 | 낮음 (SQL 작성 완료 → `query()` 함수로 교체 완료) |
|
||||||
|
| 우선순위 | 🟢 낮음 (Phase 2.4) |
|
||||||
|
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ **13개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||||
|
- 11개 `$queryRaw` → `query()` 함수로 교체
|
||||||
|
- 2개 ORM 메서드 → `query()` (SQL 새로 작성)
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 1. `$queryRaw` / `$queryRawUnsafe` 사용 (11개)
|
||||||
|
|
||||||
|
**현재 상태**: SQL은 이미 작성되어 있음 ✅
|
||||||
|
**전환 작업**: `prisma.$queryRaw` → `query()` 함수로 교체만 하면 됨
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존
|
||||||
|
await prisma.$queryRaw<Array<{ column_name; data_type }>>`...`;
|
||||||
|
await prisma.$queryRawUnsafe(upsertQuery, ...values);
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
await query<Array<{ column_name: string; data_type: string }>>(`...`);
|
||||||
|
await query(upsertQuery, values);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. ORM 메서드 사용 (2개)
|
||||||
|
|
||||||
|
**현재 상태**: Prisma ORM 메서드 사용
|
||||||
|
**전환 작업**: SQL 작성 필요
|
||||||
|
|
||||||
|
#### 1. dynamic_form_data 조회 (1개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 867: 폼 데이터 조회
|
||||||
|
const result = await prisma.dynamic_form_data.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { data: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. screen_layouts 조회 (1개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 1101: 화면 레이아웃 조회
|
||||||
|
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||||
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
component_type: "widget",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
component_id: true,
|
||||||
|
properties: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: dynamic_form_data 조회 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await prisma.dynamic_form_data.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { data: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { queryOne } from "../database/db";
|
||||||
|
|
||||||
|
const result = await queryOne<{ data: any }>(
|
||||||
|
`SELECT data FROM dynamic_form_data WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: screen_layouts 조회 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const screenLayouts = await prisma.screen_layouts.findMany({
|
||||||
|
where: {
|
||||||
|
screen_id: screenId,
|
||||||
|
component_type: "widget",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
component_id: true,
|
||||||
|
properties: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const screenLayouts = await query<{
|
||||||
|
component_id: string;
|
||||||
|
properties: any;
|
||||||
|
}>(
|
||||||
|
`SELECT component_id, properties
|
||||||
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1 AND component_type = $2`,
|
||||||
|
[screenId, "widget"]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (5개)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("DynamicFormService Raw Query 전환 테스트", () => {
|
||||||
|
describe("getFormDataById", () => {
|
||||||
|
test("폼 데이터 조회 성공", async () => { ... });
|
||||||
|
test("존재하지 않는 데이터", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getScreenLayoutsForControl", () => {
|
||||||
|
test("화면 레이아웃 조회 성공", async () => { ... });
|
||||||
|
test("widget 타입만 필터링", async () => { ... });
|
||||||
|
test("빈 결과 처리", async () => { ... });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (3개 시나리오)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("동적 폼 통합 테스트", () => {
|
||||||
|
test("폼 데이터 UPSERT → 조회", async () => { ... });
|
||||||
|
test("폼 데이터 업데이트 → 조회", async () => { ... });
|
||||||
|
test("화면 레이아웃 조회 → 제어 설정 확인", async () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 전환 완료 내역
|
||||||
|
|
||||||
|
### ✅ 전환된 함수들 (13개 Raw Query 호출)
|
||||||
|
|
||||||
|
1. **getTableColumnInfo()** - 컬럼 정보 조회
|
||||||
|
2. **getPrimaryKeyColumns()** - 기본 키 조회
|
||||||
|
3. **getNotNullColumns()** - NOT NULL 컬럼 조회
|
||||||
|
4. **upsertFormData()** - UPSERT 실행
|
||||||
|
5. **partialUpdateFormData()** - 부분 업데이트
|
||||||
|
6. **updateFormData()** - 전체 업데이트
|
||||||
|
7. **deleteFormData()** - 데이터 삭제
|
||||||
|
8. **getFormDataById()** - 폼 데이터 조회
|
||||||
|
9. **getTableColumns()** - 테이블 컬럼 조회
|
||||||
|
10. **getTablePrimaryKeys()** - 기본 키 조회
|
||||||
|
11. **getScreenLayoutsForControl()** - 화면 레이아웃 조회
|
||||||
|
|
||||||
|
### 🔧 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||||
|
2. **동적 UPSERT 쿼리**: PostgreSQL ON CONFLICT 구문 사용
|
||||||
|
3. **부분 업데이트**: 동적 SET 절 생성
|
||||||
|
4. **타입 변환**: PostgreSQL 타입 자동 변환 로직 유지
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 1단계: ORM 호출 전환 ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `getFormDataById()` - queryOne 전환
|
||||||
|
- [x] `getScreenLayoutsForControl()` - query 전환
|
||||||
|
- [x] 모든 Raw Query 함수 전환
|
||||||
|
|
||||||
|
### 2단계: 테스트 & 검증 ⏳ **진행 예정**
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (5개)
|
||||||
|
- [ ] 통합 테스트 작성 (3개 시나리오)
|
||||||
|
- [x] Prisma import 완전 제거 확인 ✅
|
||||||
|
- [ ] 성능 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [x] **13개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||||
|
- [x] 11개 `$queryRaw` → `query()` 함수로 교체 ✅
|
||||||
|
- [x] 2개 ORM 메서드 → `query()` 함수로 전환 ✅
|
||||||
|
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||||
|
- [x] **`import prisma` 완전 제거** ✅
|
||||||
|
- [ ] **모든 단위 테스트 통과 (5개)** ⏳
|
||||||
|
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)** ⏳
|
||||||
|
- [ ] **성능 저하 없음** ⏳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**완료일**: 2025-10-01
|
||||||
|
**소요 시간**: 완료됨 (이전에 전환)
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟢 낮음 (Phase 2.4)
|
||||||
|
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||||
|
**특이사항**: SQL은 이미 작성되어 있었고, `query()` 함수로 교체 완료
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
# 🔌 Phase 2.5: ExternalDbConnectionService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
ExternalDbConnectionService는 **15개의 Prisma 호출**이 있으며, 외부 데이터베이스 연결 정보를 관리하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ---------------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/externalDbConnectionService.ts` |
|
||||||
|
| 파일 크기 | 1,100+ 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **15/15 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 중간 (CRUD + 연결 테스트) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 2.5) |
|
||||||
|
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ 15개 Prisma 호출을 모두 Raw Query로 전환
|
||||||
|
- ✅ 민감 정보 암호화 처리 유지
|
||||||
|
- ✅ 연결 테스트 로직 정상 동작
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 주요 기능
|
||||||
|
|
||||||
|
### 1. 외부 DB 연결 정보 CRUD
|
||||||
|
|
||||||
|
- 생성, 조회, 수정, 삭제
|
||||||
|
- 연결 정보 암호화/복호화
|
||||||
|
|
||||||
|
### 2. 연결 테스트
|
||||||
|
|
||||||
|
- MySQL, PostgreSQL, MSSQL, Oracle 연결 테스트
|
||||||
|
|
||||||
|
### 3. 연결 정보 관리
|
||||||
|
|
||||||
|
- 회사별 연결 정보 조회
|
||||||
|
- 활성/비활성 상태 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 예상 전환 패턴
|
||||||
|
|
||||||
|
### CRUD 작업
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 생성
|
||||||
|
await query(
|
||||||
|
`INSERT INTO external_db_connections
|
||||||
|
(connection_name, db_type, host, port, database_name, username, password, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 조회
|
||||||
|
await query(
|
||||||
|
`SELECT * FROM external_db_connections
|
||||||
|
WHERE company_code = $1 AND is_active = 'Y'`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 수정
|
||||||
|
await query(
|
||||||
|
`UPDATE external_db_connections
|
||||||
|
SET connection_name = $1, host = $2, ...
|
||||||
|
WHERE connection_id = $2`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 삭제 (소프트)
|
||||||
|
await query(
|
||||||
|
`UPDATE external_db_connections
|
||||||
|
SET is_active = 'N'
|
||||||
|
WHERE connection_id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 전환 완료 내역
|
||||||
|
|
||||||
|
### ✅ 전환된 함수들 (15개 Prisma 호출)
|
||||||
|
|
||||||
|
1. **getConnections()** - 동적 WHERE 조건 생성으로 전환
|
||||||
|
2. **getConnectionsGroupedByType()** - DB 타입 카테고리 조회
|
||||||
|
3. **getConnectionById()** - 단일 연결 조회 (비밀번호 마스킹)
|
||||||
|
4. **getConnectionByIdWithPassword()** - 비밀번호 포함 조회
|
||||||
|
5. **createConnection()** - 새 연결 생성 + 중복 확인
|
||||||
|
6. **updateConnection()** - 동적 필드 업데이트
|
||||||
|
7. **deleteConnection()** - 물리 삭제
|
||||||
|
8. **testConnectionById()** - 연결 테스트용 조회
|
||||||
|
9. **getDecryptedPassword()** - 비밀번호 복호화용 조회
|
||||||
|
10. **executeQuery()** - 쿼리 실행용 조회
|
||||||
|
11. **getTables()** - 테이블 목록 조회용
|
||||||
|
|
||||||
|
### 🔧 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **동적 WHERE 조건 생성**: 필터 조건에 따라 동적으로 SQL 생성
|
||||||
|
2. **동적 UPDATE 쿼리**: 변경된 필드만 업데이트하도록 구현
|
||||||
|
3. **ILIKE 검색**: 대소문자 구분 없는 검색 지원
|
||||||
|
4. **암호화 로직 유지**: PasswordEncryption 클래스와 통합 유지
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [x] **15개 Prisma 호출 모두 Raw Query로 전환** ✅
|
||||||
|
- [x] **암호화/복호화 로직 정상 동작** ✅
|
||||||
|
- [x] **연결 테스트 정상 동작** ✅
|
||||||
|
- [ ] **모든 단위 테스트 통과 (10개 이상)** ⏳
|
||||||
|
- [x] **Prisma import 완전 제거** ✅
|
||||||
|
- [x] **TypeScript 컴파일 성공** ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**완료일**: 2025-10-01
|
||||||
|
**소요 시간**: 1시간
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟡 중간 (Phase 2.5)
|
||||||
|
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
# 🎮 Phase 2.6: DataflowControlService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DataflowControlService는 **6개의 Prisma 호출**이 있으며, 데이터플로우 제어 및 실행을 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ----------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/dataflowControlService.ts` |
|
||||||
|
| 파일 크기 | 1,100+ 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 높음 (복잡한 비즈니스 로직) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 2.6) |
|
||||||
|
| **상태** | ✅ **전환 완료 및 컴파일 성공** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ **6개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||||
|
- ✅ 복잡한 비즈니스 로직 정상 동작 확인
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
|
||||||
|
1. **데이터플로우 실행 관리**
|
||||||
|
- 관계 기반 데이터 조회 및 저장
|
||||||
|
- 조건부 실행 로직
|
||||||
|
2. **트랜잭션 처리**
|
||||||
|
- 여러 테이블에 걸친 데이터 처리
|
||||||
|
3. **데이터 변환 및 매핑**
|
||||||
|
- 소스-타겟 데이터 변환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 계획
|
||||||
|
|
||||||
|
### 1단계: 기본 조회 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `getRelationshipById()` - 관계 정보 조회
|
||||||
|
- `getDataflowConfig()` - 데이터플로우 설정 조회
|
||||||
|
|
||||||
|
### 2단계: 데이터 실행 로직 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `executeDataflow()` - 데이터플로우 실행
|
||||||
|
- `validateDataflow()` - 데이터플로우 검증
|
||||||
|
|
||||||
|
### 3단계: 복잡한 기능 - 트랜잭션 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `executeWithTransaction()` - 트랜잭션 내 실행
|
||||||
|
- `rollbackOnError()` - 에러 시 롤백
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 관계 정보 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const relationship = await prisma.table_relationship.findUnique({
|
||||||
|
where: { relationship_id: relationshipId },
|
||||||
|
include: {
|
||||||
|
source_table: true,
|
||||||
|
target_table: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const relationship = await query<TableRelationship>(
|
||||||
|
`SELECT
|
||||||
|
tr.*,
|
||||||
|
st.table_name as source_table_name,
|
||||||
|
tt.table_name as target_table_name
|
||||||
|
FROM table_relationship tr
|
||||||
|
LEFT JOIN table_labels st ON tr.source_table_id = st.table_id
|
||||||
|
LEFT JOIN table_labels tt ON tr.target_table_id = tt.table_id
|
||||||
|
WHERE tr.relationship_id = $1`,
|
||||||
|
[relationshipId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 트랜잭션 내 실행
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// 소스 데이터 조회
|
||||||
|
const sourceData = await tx.dynamic_form_data.findMany(...);
|
||||||
|
|
||||||
|
// 타겟 데이터 저장
|
||||||
|
await tx.dynamic_form_data.createMany(...);
|
||||||
|
|
||||||
|
// 실행 로그 저장
|
||||||
|
await tx.dataflow_execution_log.create(...);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { transaction } from "../database/db";
|
||||||
|
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 소스 데이터 조회
|
||||||
|
const sourceData = await client.query(
|
||||||
|
`SELECT * FROM dynamic_form_data WHERE ...`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 타겟 데이터 저장
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO dynamic_form_data (...) VALUES (...)`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 실행 로그 저장
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO dataflow_execution_log (...) VALUES (...)`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 5단계: 테스트 & 검증
|
||||||
|
|
||||||
|
### 단위 테스트 (10개)
|
||||||
|
|
||||||
|
- [ ] getRelationshipById - 관계 정보 조회
|
||||||
|
- [ ] getDataflowConfig - 설정 조회
|
||||||
|
- [ ] executeDataflow - 데이터플로우 실행
|
||||||
|
- [ ] validateDataflow - 검증
|
||||||
|
- [ ] executeWithTransaction - 트랜잭션 실행
|
||||||
|
- [ ] rollbackOnError - 에러 처리
|
||||||
|
- [ ] transformData - 데이터 변환
|
||||||
|
- [ ] mapSourceToTarget - 필드 매핑
|
||||||
|
- [ ] applyConditions - 조건 적용
|
||||||
|
- [ ] logExecution - 실행 로그
|
||||||
|
|
||||||
|
### 통합 테스트 (4개 시나리오)
|
||||||
|
|
||||||
|
1. **데이터플로우 실행 시나리오**
|
||||||
|
- 관계 조회 → 데이터 실행 → 로그 저장
|
||||||
|
2. **트랜잭션 테스트**
|
||||||
|
- 여러 테이블 동시 처리
|
||||||
|
- 에러 발생 시 롤백
|
||||||
|
3. **조건부 실행 테스트**
|
||||||
|
- 조건에 따른 데이터 처리
|
||||||
|
4. **데이터 변환 테스트**
|
||||||
|
- 소스-타겟 데이터 매핑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 전환 완료 내역
|
||||||
|
|
||||||
|
### ✅ 전환된 함수들 (6개 Prisma 호출)
|
||||||
|
|
||||||
|
1. **executeDataflowControl()** - 관계도 정보 조회 (findUnique → queryOne)
|
||||||
|
2. **evaluateActionConditions()** - 대상 테이블 조건 확인 ($queryRawUnsafe → query)
|
||||||
|
3. **executeInsertAction()** - INSERT 실행 ($executeRawUnsafe → query)
|
||||||
|
4. **executeUpdateAction()** - UPDATE 실행 ($executeRawUnsafe → query)
|
||||||
|
5. **executeDeleteAction()** - DELETE 실행 ($executeRawUnsafe → query)
|
||||||
|
6. **checkColumnExists()** - 컬럼 존재 확인 ($queryRawUnsafe → query)
|
||||||
|
|
||||||
|
### 🔧 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **Prisma import 완전 제거**: `import { query, queryOne } from "../database/db"`
|
||||||
|
2. **동적 테이블 쿼리 전환**: `$queryRawUnsafe` / `$executeRawUnsafe` → `query()`
|
||||||
|
3. **파라미터 바인딩 수정**: MySQL `?` → PostgreSQL `$1, $2...`
|
||||||
|
4. **복잡한 비즈니스 로직 유지**: 조건부 실행, 다중 커넥션, 에러 처리
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [x] **6개 모든 Prisma 호출을 Raw Query로 전환 완료** ✅
|
||||||
|
- [x] **모든 TypeScript 컴파일 오류 해결** ✅
|
||||||
|
- [x] **`import prisma` 완전 제거** ✅
|
||||||
|
- [ ] **트랜잭션 정상 동작 확인** ⏳
|
||||||
|
- [ ] **복잡한 비즈니스 로직 정상 동작** ⏳
|
||||||
|
- [ ] **모든 단위 테스트 통과 (10개)** ⏳
|
||||||
|
- [ ] **모든 통합 테스트 작성 완료 (4개 시나리오)** ⏳
|
||||||
|
- [ ] **성능 저하 없음** ⏳
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### 복잡한 비즈니스 로직
|
||||||
|
|
||||||
|
이 서비스는 데이터플로우 제어라는 복잡한 비즈니스 로직을 처리합니다:
|
||||||
|
|
||||||
|
- 조건부 실행 로직
|
||||||
|
- 데이터 변환 및 매핑
|
||||||
|
- 트랜잭션 관리
|
||||||
|
- 에러 처리 및 롤백
|
||||||
|
|
||||||
|
### 성능 최적화 중요
|
||||||
|
|
||||||
|
데이터플로우 실행은 대량의 데이터를 처리할 수 있으므로:
|
||||||
|
|
||||||
|
- 배치 처리 고려
|
||||||
|
- 인덱스 활용
|
||||||
|
- 쿼리 최적화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**완료일**: 2025-10-01
|
||||||
|
**소요 시간**: 30분
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟡 중간 (Phase 2.6)
|
||||||
|
**상태**: ✅ **전환 완료** (테스트 필요)
|
||||||
|
**특이사항**: 복잡한 비즈니스 로직이 포함되어 있어 신중한 테스트 필요
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
# 🔧 Phase 2.7: DDLExecutionService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DDLExecutionService는 **4개의 Prisma 호출**이 있으며, DDL(Data Definition Language) 실행 및 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | -------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||||
|
| 파일 크기 | 400+ 라인 |
|
||||||
|
| Prisma 호출 | 4개 |
|
||||||
|
| **현재 진행률** | **6/6 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 중간 (DDL 실행 + 로그 관리) |
|
||||||
|
| 우선순위 | 🔴 최우선 (테이블 추가 기능 - Phase 2.3으로 변경) |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ **4개 모든 Prisma 호출을 `db.ts`의 `query()` 함수로 교체**
|
||||||
|
- ✅ DDL 실행 정상 동작 확인
|
||||||
|
- ✅ 모든 단위 테스트 통과
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
|
||||||
|
1. **DDL 실행**
|
||||||
|
- CREATE TABLE, ALTER TABLE, DROP TABLE
|
||||||
|
- CREATE INDEX, DROP INDEX
|
||||||
|
2. **실행 로그 관리**
|
||||||
|
- DDL 실행 이력 저장
|
||||||
|
- 에러 로그 관리
|
||||||
|
3. **롤백 지원**
|
||||||
|
- DDL 롤백 SQL 생성 및 실행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 계획
|
||||||
|
|
||||||
|
### 1단계: DDL 실행 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `executeDDL()` - DDL 실행
|
||||||
|
- `validateDDL()` - DDL 문법 검증
|
||||||
|
|
||||||
|
### 2단계: 로그 관리 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `saveDDLLog()` - 실행 로그 저장
|
||||||
|
- `getDDLHistory()` - 실행 이력 조회
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: DDL 실행 및 로그 저장
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
await prisma.$executeRawUnsafe(ddlQuery);
|
||||||
|
|
||||||
|
await prisma.ddl_execution_log.create({
|
||||||
|
data: {
|
||||||
|
ddl_statement: ddlQuery,
|
||||||
|
execution_status: "SUCCESS",
|
||||||
|
executed_by: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
await query(ddlQuery);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ddl_execution_log
|
||||||
|
(ddl_statement, execution_status, executed_by, executed_date)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[ddlQuery, "SUCCESS", userId, new Date()]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: DDL 실행 이력 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const history = await prisma.ddl_execution_log.findMany({
|
||||||
|
where: {
|
||||||
|
company_code: companyCode,
|
||||||
|
execution_status: "SUCCESS",
|
||||||
|
},
|
||||||
|
orderBy: { executed_date: "desc" },
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const history = await query<DDLLog[]>(
|
||||||
|
`SELECT * FROM ddl_execution_log
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND execution_status = $2
|
||||||
|
ORDER BY executed_date DESC
|
||||||
|
LIMIT $3`,
|
||||||
|
[companyCode, "SUCCESS", 50]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 3단계: 테스트 & 검증
|
||||||
|
|
||||||
|
### 단위 테스트 (8개)
|
||||||
|
|
||||||
|
- [ ] executeDDL - CREATE TABLE
|
||||||
|
- [ ] executeDDL - ALTER TABLE
|
||||||
|
- [ ] executeDDL - DROP TABLE
|
||||||
|
- [ ] executeDDL - CREATE INDEX
|
||||||
|
- [ ] validateDDL - 문법 검증
|
||||||
|
- [ ] saveDDLLog - 로그 저장
|
||||||
|
- [ ] getDDLHistory - 이력 조회
|
||||||
|
- [ ] rollbackDDL - DDL 롤백
|
||||||
|
|
||||||
|
### 통합 테스트 (3개 시나리오)
|
||||||
|
|
||||||
|
1. **테이블 생성 → 로그 저장 → 이력 조회**
|
||||||
|
2. **DDL 실행 실패 → 에러 로그 저장**
|
||||||
|
3. **DDL 롤백 테스트**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- [ ] **4개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||||
|
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- [ ] **DDL 실행 정상 동작 확인**
|
||||||
|
- [ ] **모든 단위 테스트 통과 (8개)**
|
||||||
|
- [ ] **모든 통합 테스트 작성 완료 (3개 시나리오)**
|
||||||
|
- [ ] **`import prisma` 완전 제거 및 `import { query } from "../database/db"` 사용**
|
||||||
|
- [ ] **성능 저하 없음**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### DDL 실행의 위험성
|
||||||
|
|
||||||
|
DDL은 데이터베이스 스키마를 변경하므로 매우 신중하게 처리해야 합니다:
|
||||||
|
|
||||||
|
- 실행 전 검증 필수
|
||||||
|
- 롤백 SQL 자동 생성
|
||||||
|
- 실행 이력 철저히 관리
|
||||||
|
|
||||||
|
### 트랜잭션 지원 제한
|
||||||
|
|
||||||
|
PostgreSQL에서 일부 DDL은 트랜잭션을 지원하지만, 일부는 자동 커밋됩니다:
|
||||||
|
|
||||||
|
- CREATE TABLE: 트랜잭션 지원 ✅
|
||||||
|
- DROP TABLE: 트랜잭션 지원 ✅
|
||||||
|
- CREATE INDEX CONCURRENTLY: 트랜잭션 미지원 ❌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**예상 소요 시간**: 0.5일
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟢 낮음 (Phase 2.7)
|
||||||
|
**상태**: ⏳ **진행 예정**
|
||||||
|
**특이사항**: DDL 실행의 특성상 신중한 테스트 필요
|
||||||
|
|
@ -0,0 +1,566 @@
|
||||||
|
# 🖥️ Phase 2.1: ScreenManagementService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
ScreenManagementService는 **46개의 Prisma 호출**이 있는 가장 복잡한 서비스입니다. 화면 정의, 레이아웃, 메뉴 할당, 템플릿 등 다양한 기능을 포함합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------------ |
|
||||||
|
| 파일 위치 | `backend-node/src/services/screenManagementService.ts` |
|
||||||
|
| 파일 크기 | 1,700+ 라인 |
|
||||||
|
| Prisma 호출 | 46개 |
|
||||||
|
| **현재 진행률** | **46/46 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 매우 높음 |
|
||||||
|
| 우선순위 | 🔴 최우선 |
|
||||||
|
|
||||||
|
### 🎯 전환 현황 (2025-09-30 업데이트)
|
||||||
|
|
||||||
|
- ✅ **Stage 1 완료**: 기본 CRUD (8개 함수) - Commit: 13c1bc4, 0e8d1d4
|
||||||
|
- ✅ **Stage 2 완료**: 레이아웃 관리 (2개 함수, 4 Prisma 호출) - Commit: 67dced7
|
||||||
|
- ✅ **Stage 3 완료**: 템플릿 & 메뉴 관리 (5개 함수) - Commit: 74351e8
|
||||||
|
- ✅ **Stage 4 완료**: 복잡한 기능 (트랜잭션) - **모든 46개 Prisma 호출 전환 완료**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 1. 화면 정의 관리 (Screen Definitions) - 18개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 53: 화면 코드 중복 확인
|
||||||
|
await prisma.screen_definitions.findFirst({ where: { screen_code, is_active: { not: "D" } } })
|
||||||
|
|
||||||
|
// Line 70: 화면 생성
|
||||||
|
await prisma.screen_definitions.create({ data: { ... } })
|
||||||
|
|
||||||
|
// Line 99: 화면 목록 조회 (페이징)
|
||||||
|
await prisma.screen_definitions.findMany({ where, skip, take, orderBy })
|
||||||
|
|
||||||
|
// Line 105: 화면 총 개수
|
||||||
|
await prisma.screen_definitions.count({ where })
|
||||||
|
|
||||||
|
// Line 166: 전체 화면 목록
|
||||||
|
await prisma.screen_definitions.findMany({ where })
|
||||||
|
|
||||||
|
// Line 178: 화면 코드로 조회
|
||||||
|
await prisma.screen_definitions.findFirst({ where: { screen_code } })
|
||||||
|
|
||||||
|
// Line 205: 화면 ID로 조회
|
||||||
|
await prisma.screen_definitions.findFirst({ where: { screen_id } })
|
||||||
|
|
||||||
|
// Line 221: 화면 존재 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where: { screen_id } })
|
||||||
|
|
||||||
|
// Line 236: 화면 업데이트
|
||||||
|
await prisma.screen_definitions.update({ where, data })
|
||||||
|
|
||||||
|
// Line 268: 화면 복사 - 원본 조회
|
||||||
|
await prisma.screen_definitions.findUnique({ where, include: { screen_layouts } })
|
||||||
|
|
||||||
|
// Line 292: 화면 순서 변경 - 전체 조회
|
||||||
|
await prisma.screen_definitions.findMany({ where })
|
||||||
|
|
||||||
|
// Line 486: 화면 템플릿 적용 - 존재 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where })
|
||||||
|
|
||||||
|
// Line 557: 화면 복사 - 존재 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where })
|
||||||
|
|
||||||
|
// Line 578: 화면 복사 - 중복 확인
|
||||||
|
await prisma.screen_definitions.findFirst({ where })
|
||||||
|
|
||||||
|
// Line 651: 화면 삭제 - 존재 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where })
|
||||||
|
|
||||||
|
// Line 672: 화면 삭제 (물리 삭제)
|
||||||
|
await prisma.screen_definitions.delete({ where })
|
||||||
|
|
||||||
|
// Line 700: 삭제된 화면 조회
|
||||||
|
await prisma.screen_definitions.findMany({ where: { is_active: "D" } })
|
||||||
|
|
||||||
|
// Line 706: 삭제된 화면 개수
|
||||||
|
await prisma.screen_definitions.count({ where })
|
||||||
|
|
||||||
|
// Line 763: 일괄 삭제 - 화면 조회
|
||||||
|
await prisma.screen_definitions.findMany({ where })
|
||||||
|
|
||||||
|
// Line 1083: 레이아웃 저장 - 화면 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where })
|
||||||
|
|
||||||
|
// Line 1181: 레이아웃 조회 - 화면 확인
|
||||||
|
await prisma.screen_definitions.findUnique({ where })
|
||||||
|
|
||||||
|
// Line 1655: 위젯 데이터 저장 - 화면 존재 확인
|
||||||
|
await prisma.screen_definitions.findMany({ where })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 레이아웃 관리 (Screen Layouts) - 4개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 1096: 레이아웃 삭제
|
||||||
|
await prisma.screen_layouts.deleteMany({ where: { screen_id } });
|
||||||
|
|
||||||
|
// Line 1107: 레이아웃 생성 (단일)
|
||||||
|
await prisma.screen_layouts.create({ data });
|
||||||
|
|
||||||
|
// Line 1152: 레이아웃 생성 (다중)
|
||||||
|
await prisma.screen_layouts.create({ data });
|
||||||
|
|
||||||
|
// Line 1193: 레이아웃 조회
|
||||||
|
await prisma.screen_layouts.findMany({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 템플릿 관리 (Screen Templates) - 2개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 1303: 템플릿 목록 조회
|
||||||
|
await prisma.screen_templates.findMany({ where });
|
||||||
|
|
||||||
|
// Line 1317: 템플릿 생성
|
||||||
|
await prisma.screen_templates.create({ data });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 메뉴 할당 (Screen Menu Assignments) - 5개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 446: 메뉴 할당 조회
|
||||||
|
await prisma.screen_menu_assignments.findMany({ where });
|
||||||
|
|
||||||
|
// Line 1346: 메뉴 할당 중복 확인
|
||||||
|
await prisma.screen_menu_assignments.findFirst({ where });
|
||||||
|
|
||||||
|
// Line 1358: 메뉴 할당 생성
|
||||||
|
await prisma.screen_menu_assignments.create({ data });
|
||||||
|
|
||||||
|
// Line 1376: 화면별 메뉴 할당 조회
|
||||||
|
await prisma.screen_menu_assignments.findMany({ where });
|
||||||
|
|
||||||
|
// Line 1401: 메뉴 할당 삭제
|
||||||
|
await prisma.screen_menu_assignments.deleteMany({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 테이블 레이블 (Table Labels) - 3개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 117: 테이블 레이블 조회 (페이징)
|
||||||
|
await prisma.table_labels.findMany({ where, skip, take });
|
||||||
|
|
||||||
|
// Line 713: 테이블 레이블 조회 (전체)
|
||||||
|
await prisma.table_labels.findMany({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 컬럼 레이블 (Column Labels) - 2개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 948: 웹타입 정보 조회
|
||||||
|
await prisma.column_labels.findMany({ where, select });
|
||||||
|
|
||||||
|
// Line 1456: 컬럼 레이블 UPSERT
|
||||||
|
await prisma.column_labels.upsert({ where, create, update });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Raw Query 사용 (이미 있음) - 6개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 627: 화면 순서 변경 (일괄 업데이트)
|
||||||
|
await prisma.$executeRaw`UPDATE screen_definitions SET display_order = ...`;
|
||||||
|
|
||||||
|
// Line 833: 테이블 목록 조회
|
||||||
|
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||||
|
|
||||||
|
// Line 876: 테이블 존재 확인
|
||||||
|
await prisma.$queryRaw<Array<{ table_name: string }>>`SELECT tablename ...`;
|
||||||
|
|
||||||
|
// Line 922: 테이블 컬럼 정보 조회
|
||||||
|
await prisma.$queryRaw<Array<ColumnInfo>>`SELECT column_name, data_type ...`;
|
||||||
|
|
||||||
|
// Line 1418: 컬럼 정보 조회 (상세)
|
||||||
|
await prisma.$queryRaw`SELECT column_name, data_type ...`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 트랜잭션 사용 - 3개
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 521: 화면 템플릿 적용 트랜잭션
|
||||||
|
await prisma.$transaction(async (tx) => { ... })
|
||||||
|
|
||||||
|
// Line 593: 화면 복사 트랜잭션
|
||||||
|
await prisma.$transaction(async (tx) => { ... })
|
||||||
|
|
||||||
|
// Line 788: 일괄 삭제 트랜잭션
|
||||||
|
await prisma.$transaction(async (tx) => { ... })
|
||||||
|
|
||||||
|
// Line 1697: 위젯 데이터 저장 트랜잭션
|
||||||
|
await prisma.$transaction(async (tx) => { ... })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 전환 전략
|
||||||
|
|
||||||
|
### 전략 1: 단계적 전환
|
||||||
|
|
||||||
|
1. **1단계**: 단순 CRUD 전환 (findFirst, findMany, create, update, delete)
|
||||||
|
2. **2단계**: 복잡한 조회 전환 (include, join)
|
||||||
|
3. **3단계**: 트랜잭션 전환
|
||||||
|
4. **4단계**: Raw Query 개선
|
||||||
|
|
||||||
|
### 전략 2: 함수별 전환 우선순위
|
||||||
|
|
||||||
|
#### 🔴 최우선 (기본 CRUD)
|
||||||
|
|
||||||
|
- `createScreen()` - Line 70
|
||||||
|
- `getScreensByCompany()` - Line 99-105
|
||||||
|
- `getScreenByCode()` - Line 178
|
||||||
|
- `getScreenById()` - Line 205
|
||||||
|
- `updateScreen()` - Line 236
|
||||||
|
- `deleteScreen()` - Line 672
|
||||||
|
|
||||||
|
#### 🟡 2순위 (레이아웃)
|
||||||
|
|
||||||
|
- `saveLayout()` - Line 1096-1152
|
||||||
|
- `getLayout()` - Line 1193
|
||||||
|
- `deleteLayout()` - Line 1096
|
||||||
|
|
||||||
|
#### 🟢 3순위 (템플릿 & 메뉴)
|
||||||
|
|
||||||
|
- `getTemplates()` - Line 1303
|
||||||
|
- `createTemplate()` - Line 1317
|
||||||
|
- `assignToMenu()` - Line 1358
|
||||||
|
- `getMenuAssignments()` - Line 1376
|
||||||
|
- `removeMenuAssignment()` - Line 1401
|
||||||
|
|
||||||
|
#### 🔵 4순위 (복잡한 기능)
|
||||||
|
|
||||||
|
- `copyScreen()` - Line 593 (트랜잭션)
|
||||||
|
- `applyTemplate()` - Line 521 (트랜잭션)
|
||||||
|
- `bulkDelete()` - Line 788 (트랜잭션)
|
||||||
|
- `reorderScreens()` - Line 627 (Raw Query)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: createScreen() 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 53: 중복 확인
|
||||||
|
const existingScreen = await prisma.screen_definitions.findFirst({
|
||||||
|
where: {
|
||||||
|
screen_code: screenData.screenCode,
|
||||||
|
is_active: { not: "D" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 70: 생성
|
||||||
|
const screen = await prisma.screen_definitions.create({
|
||||||
|
data: {
|
||||||
|
screen_name: screenData.screenName,
|
||||||
|
screen_code: screenData.screenCode,
|
||||||
|
table_name: screenData.tableName,
|
||||||
|
company_code: screenData.companyCode,
|
||||||
|
description: screenData.description,
|
||||||
|
created_by: screenData.createdBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
// 중복 확인
|
||||||
|
const existingResult = await query<{ screen_id: number }>(
|
||||||
|
`SELECT screen_id FROM screen_definitions
|
||||||
|
WHERE screen_code = $1 AND is_active != 'D'
|
||||||
|
LIMIT 1`,
|
||||||
|
[screenData.screenCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingResult.length > 0) {
|
||||||
|
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생성
|
||||||
|
const [screen] = await query<ScreenDefinition>(
|
||||||
|
`INSERT INTO screen_definitions (
|
||||||
|
screen_name, screen_code, table_name, company_code, description, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
screenData.screenName,
|
||||||
|
screenData.screenCode,
|
||||||
|
screenData.tableName,
|
||||||
|
screenData.companyCode,
|
||||||
|
screenData.description,
|
||||||
|
screenData.createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: getScreensByCompany() 전환 (페이징)
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [screens, total] = await Promise.all([
|
||||||
|
prisma.screen_definitions.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: (page - 1) * size,
|
||||||
|
take: size,
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.screen_definitions.count({ where: whereClause }),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const offset = (page - 1) * size;
|
||||||
|
const whereSQL =
|
||||||
|
companyCode !== "*"
|
||||||
|
? "WHERE company_code = $1 AND is_active != 'D'"
|
||||||
|
: "WHERE is_active != 'D'";
|
||||||
|
const params =
|
||||||
|
companyCode !== "*" ? [companyCode, size, offset] : [size, offset];
|
||||||
|
|
||||||
|
const [screens, totalResult] = await Promise.all([
|
||||||
|
query<ScreenDefinition>(
|
||||||
|
`SELECT * FROM screen_definitions
|
||||||
|
${whereSQL}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||||
|
params
|
||||||
|
),
|
||||||
|
query<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM screen_definitions ${whereSQL}`,
|
||||||
|
companyCode !== "*" ? [companyCode] : []
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = totalResult[0]?.count || 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 트랜잭션 전환
|
||||||
|
|
||||||
|
**기존 Prisma 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const newScreen = await tx.screen_definitions.create({ data: { ... } });
|
||||||
|
await tx.screen_layouts.createMany({ data: layouts });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**새로운 Raw Query 코드:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { transaction } from "../database/db";
|
||||||
|
|
||||||
|
await transaction(async (client) => {
|
||||||
|
const [newScreen] = await client.query(
|
||||||
|
`INSERT INTO screen_definitions (...) VALUES (...) RETURNING *`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const layout of layouts) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO screen_layouts (...) VALUES (...)`,
|
||||||
|
[...]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("ScreenManagementService Raw Query 전환 테스트", () => {
|
||||||
|
describe("createScreen", () => {
|
||||||
|
test("화면 생성 성공", async () => { ... });
|
||||||
|
test("중복 화면 코드 에러", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getScreensByCompany", () => {
|
||||||
|
test("페이징 조회 성공", async () => { ... });
|
||||||
|
test("회사별 필터링", async () => { ... });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copyScreen", () => {
|
||||||
|
test("화면 복사 성공 (트랜잭션)", async () => { ... });
|
||||||
|
test("레이아웃 함께 복사", async () => { ... });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe("화면 관리 통합 테스트", () => {
|
||||||
|
test("화면 생성 → 조회 → 수정 → 삭제", async () => { ... });
|
||||||
|
test("화면 복사 → 레이아웃 확인", async () => { ... });
|
||||||
|
test("메뉴 할당 → 조회 → 해제", async () => { ... });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD (8개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `createScreen()` - 화면 생성
|
||||||
|
- [x] `getScreensByCompany()` - 화면 목록 (페이징)
|
||||||
|
- [x] `getScreenByCode()` - 화면 코드로 조회
|
||||||
|
- [x] `getScreenById()` - 화면 ID로 조회
|
||||||
|
- [x] `updateScreen()` - 화면 업데이트
|
||||||
|
- [x] `deleteScreen()` - 화면 삭제
|
||||||
|
- [x] `getScreens()` - 전체 화면 목록 조회
|
||||||
|
- [x] `getScreen()` - 회사 코드 필터링 포함 조회
|
||||||
|
|
||||||
|
### 2단계: 레이아웃 관리 (2개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `saveLayout()` - 레이아웃 저장 (메타데이터 + 컴포넌트)
|
||||||
|
- [x] `getLayout()` - 레이아웃 조회
|
||||||
|
- [x] 레이아웃 삭제 로직 (saveLayout 내부에 포함)
|
||||||
|
|
||||||
|
### 3단계: 템플릿 & 메뉴 (5개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `getTemplatesByCompany()` - 템플릿 목록
|
||||||
|
- [x] `createTemplate()` - 템플릿 생성
|
||||||
|
- [x] `assignScreenToMenu()` - 메뉴 할당
|
||||||
|
- [x] `getScreensByMenu()` - 메뉴별 화면 조회
|
||||||
|
- [x] `unassignScreenFromMenu()` - 메뉴 할당 해제
|
||||||
|
- [ ] 테이블 레이블 조회 (getScreensByCompany 내부에 포함됨)
|
||||||
|
|
||||||
|
### 4단계: 복잡한 기능 (4개 함수) ✅ **완료**
|
||||||
|
|
||||||
|
- [x] `copyScreen()` - 화면 복사 (트랜잭션)
|
||||||
|
- [x] `generateScreenCode()` - 화면 코드 자동 생성
|
||||||
|
- [x] `checkScreenDependencies()` - 화면 의존성 체크 (메뉴 할당 포함)
|
||||||
|
- [x] 모든 유틸리티 메서드 Raw Query 전환
|
||||||
|
|
||||||
|
### 5단계: 테스트 & 검증 ✅ **완료**
|
||||||
|
|
||||||
|
- [x] 단위 테스트 작성 (18개 테스트 통과)
|
||||||
|
- createScreen, updateScreen, deleteScreen
|
||||||
|
- getScreensByCompany, getScreenById
|
||||||
|
- saveLayout, getLayout
|
||||||
|
- getTemplatesByCompany, assignScreenToMenu
|
||||||
|
- copyScreen, generateScreenCode
|
||||||
|
- getTableColumns
|
||||||
|
- [x] 통합 테스트 작성 (6개 시나리오)
|
||||||
|
- 화면 생명주기 테스트 (생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제)
|
||||||
|
- 화면 복사 및 레이아웃 테스트
|
||||||
|
- 테이블 정보 조회 테스트
|
||||||
|
- 일괄 작업 테스트
|
||||||
|
- 화면 코드 자동 생성 테스트
|
||||||
|
- [x] Prisma import 완전 제거 확인
|
||||||
|
- [ ] 성능 테스트 (추후 실행 예정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 완료 기준
|
||||||
|
|
||||||
|
- ✅ **46개 Prisma 호출 모두 Raw Query로 전환 완료**
|
||||||
|
- ✅ **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- ✅ **트랜잭션 정상 동작 확인**
|
||||||
|
- ✅ **에러 처리 및 롤백 정상 동작**
|
||||||
|
- ✅ **모든 단위 테스트 통과 (18개)**
|
||||||
|
- ✅ **모든 통합 테스트 작성 완료 (6개 시나리오)**
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
- [ ] 성능 저하 없음 (기존 대비 ±10% 이내) - 추후 측정 예정
|
||||||
|
|
||||||
|
## 📊 테스트 결과
|
||||||
|
|
||||||
|
### 단위 테스트 (18개)
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ createScreen - 화면 생성 (2개 테스트)
|
||||||
|
✅ getScreensByCompany - 화면 목록 페이징 (2개 테스트)
|
||||||
|
✅ updateScreen - 화면 업데이트 (2개 테스트)
|
||||||
|
✅ deleteScreen - 화면 삭제 (2개 테스트)
|
||||||
|
✅ saveLayout - 레이아웃 저장 (2개 테스트)
|
||||||
|
- 기본 저장, 소수점 좌표 반올림 처리
|
||||||
|
✅ getLayout - 레이아웃 조회 (1개 테스트)
|
||||||
|
✅ getTemplatesByCompany - 템플릿 목록 (1개 테스트)
|
||||||
|
✅ assignScreenToMenu - 메뉴 할당 (2개 테스트)
|
||||||
|
✅ copyScreen - 화면 복사 (1개 테스트)
|
||||||
|
✅ generateScreenCode - 화면 코드 자동 생성 (2개 테스트)
|
||||||
|
✅ getTableColumns - 테이블 컬럼 정보 (1개 테스트)
|
||||||
|
|
||||||
|
Test Suites: 1 passed
|
||||||
|
Tests: 18 passed
|
||||||
|
Time: 1.922s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 테스트 (6개 시나리오)
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 화면 생명주기 테스트
|
||||||
|
- 생성 → 조회 → 수정 → 삭제 → 복원 → 영구삭제
|
||||||
|
✅ 화면 복사 및 레이아웃 테스트
|
||||||
|
- 화면 복사 → 레이아웃 저장 → 레이아웃 확인 → 레이아웃 수정
|
||||||
|
✅ 테이블 정보 조회 테스트
|
||||||
|
- 테이블 목록 조회 → 특정 테이블 정보 조회
|
||||||
|
✅ 일괄 작업 테스트
|
||||||
|
- 여러 화면 생성 → 일괄 삭제
|
||||||
|
✅ 화면 코드 자동 생성 테스트
|
||||||
|
- 순차적 화면 코드 생성 검증
|
||||||
|
✅ 메뉴 할당 테스트 (skip - 실제 메뉴 데이터 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 버그 수정 및 개선사항
|
||||||
|
|
||||||
|
### 실제 운영 환경에서 발견된 이슈
|
||||||
|
|
||||||
|
#### 1. 소수점 좌표 저장 오류 (해결 완료)
|
||||||
|
|
||||||
|
**문제**:
|
||||||
|
|
||||||
|
```
|
||||||
|
invalid input syntax for type integer: "1602.666666666667"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `position_x`, `position_y`, `width`, `height` 컬럼이 `integer` 타입
|
||||||
|
- 격자 계산 시 소수점 값이 발생하여 저장 실패
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Math.round(component.position.x), // 정수로 반올림
|
||||||
|
Math.round(component.position.y),
|
||||||
|
Math.round(component.size.width),
|
||||||
|
Math.round(component.size.height),
|
||||||
|
```
|
||||||
|
|
||||||
|
**테스트 추가**:
|
||||||
|
|
||||||
|
- 소수점 좌표 저장 테스트 케이스 추가
|
||||||
|
- 반올림 처리 검증
|
||||||
|
|
||||||
|
**영향 범위**:
|
||||||
|
|
||||||
|
- `saveLayout()` 함수
|
||||||
|
- `copyScreen()` 함수 (레이아웃 복사 시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**완료일**: 2025-09-30
|
||||||
|
**예상 소요 시간**: 2-3일 → **실제 소요 시간**: 1일
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🔴 최우선 (Phase 2.1)
|
||||||
|
**상태**: ✅ **완료**
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
# 📋 Phase 3.11: DDLAuditLogger Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DDLAuditLogger는 **8개의 Prisma 호출**이 있으며, DDL 실행 감사 로그 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | --------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/ddlAuditLogger.ts` |
|
||||||
|
| 파일 크기 | 350 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 중간 (통계 쿼리, $executeRaw) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.11) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ DDL 감사 로그 기능 정상 동작
|
||||||
|
- ⏳ 통계 쿼리 전환 (GROUP BY, COUNT, ORDER BY)
|
||||||
|
- ⏳ $executeRaw → query 전환
|
||||||
|
- ⏳ $queryRawUnsafe → query 전환
|
||||||
|
- ⏳ 동적 WHERE 조건 생성
|
||||||
|
- ⏳ TypeScript 컴파일 성공
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 Prisma 호출 (8개)
|
||||||
|
|
||||||
|
#### 1. **logDDLStart()** - DDL 시작 로그 (INSERT)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 27
|
||||||
|
const logEntry = await prisma.$executeRaw`
|
||||||
|
INSERT INTO ddl_audit_logs (
|
||||||
|
execution_id, ddl_type, table_name, status,
|
||||||
|
executed_by, company_code, started_at, metadata
|
||||||
|
) VALUES (
|
||||||
|
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||||
|
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **getAuditLogs()** - 감사 로그 목록 조회 (SELECT with filters)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 162
|
||||||
|
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||||
|
```
|
||||||
|
|
||||||
|
- 동적 WHERE 조건 생성
|
||||||
|
- 페이징 (OFFSET, LIMIT)
|
||||||
|
- 정렬 (ORDER BY)
|
||||||
|
|
||||||
|
#### 3. **getAuditStats()** - 통계 조회 (복합 쿼리)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 199 - 총 통계
|
||||||
|
const totalStats = (await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_executions,
|
||||||
|
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful,
|
||||||
|
COUNT(CASE WHEN status = 'failed' THEN 1 END) as failed,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration
|
||||||
|
FROM ddl_audit_logs
|
||||||
|
WHERE ${whereClause}`
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
// Line 212 - DDL 타입별 통계
|
||||||
|
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT ddl_type, COUNT(*) as count
|
||||||
|
FROM ddl_audit_logs
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY ddl_type
|
||||||
|
ORDER BY count DESC`
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
// Line 224 - 사용자별 통계
|
||||||
|
const userStats = (await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT executed_by, COUNT(*) as count
|
||||||
|
FROM ddl_audit_logs
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY executed_by
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10`
|
||||||
|
)) as any[];
|
||||||
|
|
||||||
|
// Line 237 - 최근 실패 로그
|
||||||
|
const recentFailures = (await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM ddl_audit_logs
|
||||||
|
WHERE status = 'failed' AND ${whereClause}
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 5`
|
||||||
|
)) as any[];
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **getExecutionHistory()** - 실행 이력 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 287
|
||||||
|
const history = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM ddl_audit_logs
|
||||||
|
WHERE table_name = $1 AND company_code = $2
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $3`,
|
||||||
|
tableName,
|
||||||
|
companyCode,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. **cleanupOldLogs()** - 오래된 로그 삭제
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 320
|
||||||
|
const result = await prisma.$executeRaw`
|
||||||
|
DELETE FROM ddl_audit_logs
|
||||||
|
WHERE started_at < NOW() - INTERVAL '${retentionDays} days'
|
||||||
|
AND company_code = ${companyCode}
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 전환 전략
|
||||||
|
|
||||||
|
### 1단계: $executeRaw 전환 (2개)
|
||||||
|
|
||||||
|
- `logDDLStart()` - INSERT
|
||||||
|
- `cleanupOldLogs()` - DELETE
|
||||||
|
|
||||||
|
### 2단계: 단순 $queryRawUnsafe 전환 (1개)
|
||||||
|
|
||||||
|
- `getExecutionHistory()` - 파라미터 바인딩 있음
|
||||||
|
|
||||||
|
### 3단계: 복잡한 $queryRawUnsafe 전환 (1개)
|
||||||
|
|
||||||
|
- `getAuditLogs()` - 동적 WHERE 조건
|
||||||
|
|
||||||
|
### 4단계: 통계 쿼리 전환 (4개)
|
||||||
|
|
||||||
|
- `getAuditStats()` 내부의 4개 쿼리
|
||||||
|
- GROUP BY, CASE WHEN, AVG, EXTRACT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: $executeRaw → query (INSERT)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const logEntry = await prisma.$executeRaw`
|
||||||
|
INSERT INTO ddl_audit_logs (
|
||||||
|
execution_id, ddl_type, table_name, status,
|
||||||
|
executed_by, company_code, started_at, metadata
|
||||||
|
) VALUES (
|
||||||
|
${executionId}, ${ddlType}, ${tableName}, 'in_progress',
|
||||||
|
${executedBy}, ${companyCode}, NOW(), ${JSON.stringify(metadata)}::jsonb
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ddl_audit_logs (
|
||||||
|
execution_id, ddl_type, table_name, status,
|
||||||
|
executed_by, company_code, started_at, metadata
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::jsonb)`,
|
||||||
|
[
|
||||||
|
executionId,
|
||||||
|
ddlType,
|
||||||
|
tableName,
|
||||||
|
"in_progress",
|
||||||
|
executedBy,
|
||||||
|
companyCode,
|
||||||
|
JSON.stringify(metadata),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 동적 WHERE 조건
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
let query = `SELECT * FROM ddl_audit_logs WHERE 1=1`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (filters.ddlType) {
|
||||||
|
query += ` AND ddl_type = ?`;
|
||||||
|
params.push(filters.ddlType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.ddlType) {
|
||||||
|
conditions.push(`ddl_type = $${paramIndex++}`);
|
||||||
|
params.push(filters.ddlType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
|
const sql = `SELECT * FROM ddl_audit_logs ${whereClause}`;
|
||||||
|
|
||||||
|
const logs = await query<any>(sql, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 통계 쿼리 (GROUP BY)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT ddl_type, COUNT(*) as count
|
||||||
|
FROM ddl_audit_logs
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY ddl_type
|
||||||
|
ORDER BY count DESC`
|
||||||
|
)) as any[];
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ddlTypeStats = await query<{ ddl_type: string; count: string }>(
|
||||||
|
`SELECT ddl_type, COUNT(*) as count
|
||||||
|
FROM ddl_audit_logs
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY ddl_type
|
||||||
|
ORDER BY count DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. JSON 필드 처리
|
||||||
|
|
||||||
|
`metadata` 필드는 JSONB 타입으로, INSERT 시 `::jsonb` 캐스팅 필요:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
JSON.stringify(metadata) + "::jsonb";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 날짜/시간 함수
|
||||||
|
|
||||||
|
- `NOW()` - 현재 시간
|
||||||
|
- `INTERVAL '30 days'` - 날짜 간격
|
||||||
|
- `EXTRACT(EPOCH FROM ...)` - 초 단위 변환
|
||||||
|
|
||||||
|
### 3. CASE WHEN 집계
|
||||||
|
|
||||||
|
```sql
|
||||||
|
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 동적 WHERE 조건
|
||||||
|
|
||||||
|
여러 필터를 조합하여 WHERE 절 생성:
|
||||||
|
|
||||||
|
- ddlType
|
||||||
|
- tableName
|
||||||
|
- status
|
||||||
|
- executedBy
|
||||||
|
- dateRange (startDate, endDate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (8개)
|
||||||
|
|
||||||
|
1. **`logDDLExecution()`** - DDL 실행 로그 INSERT
|
||||||
|
- Before: `prisma.$executeRaw`
|
||||||
|
- After: `query()` with 7 parameters
|
||||||
|
2. **`getAuditLogs()`** - 감사 로그 목록 조회
|
||||||
|
- Before: `prisma.$queryRawUnsafe`
|
||||||
|
- After: `query<any>()` with dynamic WHERE clause
|
||||||
|
3. **`getDDLStatistics()`** - 통계 조회 (4개 쿼리)
|
||||||
|
- Before: 4x `prisma.$queryRawUnsafe`
|
||||||
|
- After: 4x `query<any>()`
|
||||||
|
- totalStats: 전체 실행 통계 (CASE WHEN 집계)
|
||||||
|
- ddlTypeStats: DDL 타입별 통계 (GROUP BY)
|
||||||
|
- userStats: 사용자별 통계 (GROUP BY, LIMIT 10)
|
||||||
|
- recentFailures: 최근 실패 로그 (WHERE success = false)
|
||||||
|
4. **`getTableDDLHistory()`** - 테이블별 DDL 히스토리
|
||||||
|
- Before: `prisma.$queryRawUnsafe`
|
||||||
|
- After: `query<any>()` with table_name filter
|
||||||
|
5. **`cleanupOldLogs()`** - 오래된 로그 삭제
|
||||||
|
- Before: `prisma.$executeRaw`
|
||||||
|
- After: `query()` with date filter
|
||||||
|
|
||||||
|
### 주요 기술적 개선사항
|
||||||
|
|
||||||
|
1. **파라미터 바인딩**: PostgreSQL `$1, $2, ...` 스타일로 통일
|
||||||
|
2. **동적 WHERE 조건**: 파라미터 인덱스 자동 증가 로직 유지
|
||||||
|
3. **통계 쿼리**: CASE WHEN, GROUP BY, SUM 등 복잡한 집계 쿼리 완벽 전환
|
||||||
|
4. **에러 처리**: 기존 try-catch 구조 유지
|
||||||
|
5. **로깅**: logger 유틸리티 활용 유지
|
||||||
|
|
||||||
|
### 코드 정리
|
||||||
|
|
||||||
|
- [x] `import { PrismaClient }` 제거
|
||||||
|
- [x] `const prisma = new PrismaClient()` 제거
|
||||||
|
- [x] `import { query, queryOne }` 추가
|
||||||
|
- [x] 모든 타입 정의 유지
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] Linter 오류 없음
|
||||||
|
|
||||||
|
## 📝 원본 전환 체크리스트
|
||||||
|
|
||||||
|
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||||
|
|
||||||
|
- [ ] `logDDLStart()` - INSERT ($executeRaw → query)
|
||||||
|
- [ ] `logDDLComplete()` - UPDATE (이미 query 사용 중일 가능성)
|
||||||
|
- [ ] `logDDLError()` - UPDATE (이미 query 사용 중일 가능성)
|
||||||
|
- [ ] `getAuditLogs()` - SELECT with filters ($queryRawUnsafe → query)
|
||||||
|
- [ ] `getAuditStats()` 내 4개 쿼리:
|
||||||
|
- [ ] totalStats (집계 쿼리)
|
||||||
|
- [ ] ddlTypeStats (GROUP BY)
|
||||||
|
- [ ] userStats (GROUP BY + LIMIT)
|
||||||
|
- [ ] recentFailures (필터 + ORDER BY + LIMIT)
|
||||||
|
- [ ] `getExecutionHistory()` - SELECT with params ($queryRawUnsafe → query)
|
||||||
|
- [ ] `cleanupOldLogs()` - DELETE ($executeRaw → query)
|
||||||
|
|
||||||
|
### 2단계: 코드 정리
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
- [ ] 타입 정의 확인
|
||||||
|
|
||||||
|
### 3단계: 테스트
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (8개)
|
||||||
|
- [ ] DDL 시작 로그 테스트
|
||||||
|
- [ ] DDL 완료 로그 테스트
|
||||||
|
- [ ] 감사 로그 목록 조회 테스트
|
||||||
|
- [ ] 통계 조회 테스트
|
||||||
|
- [ ] 실행 이력 조회 테스트
|
||||||
|
- [ ] 오래된 로그 삭제 테스트
|
||||||
|
- [ ] 통합 테스트 작성 (3개)
|
||||||
|
- [ ] 전체 DDL 실행 플로우 테스트
|
||||||
|
- [ ] 필터링 및 페이징 테스트
|
||||||
|
- [ ] 통계 정확성 테스트
|
||||||
|
- [ ] 성능 테스트
|
||||||
|
- [ ] 대량 로그 조회 성능
|
||||||
|
- [ ] 통계 쿼리 성능
|
||||||
|
|
||||||
|
### 4단계: 문서화
|
||||||
|
|
||||||
|
- [ ] 전환 완료 문서 업데이트
|
||||||
|
- [ ] 주요 변경사항 기록
|
||||||
|
- [ ] 성능 벤치마크 결과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐ (중간)
|
||||||
|
- 복잡한 통계 쿼리 (GROUP BY, CASE WHEN)
|
||||||
|
- 동적 WHERE 조건 생성
|
||||||
|
- JSON 필드 처리
|
||||||
|
- **예상 소요 시간**: 1~1.5시간
|
||||||
|
- Prisma 호출 전환: 30분
|
||||||
|
- 테스트: 20분
|
||||||
|
- 문서화: 10분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 참고사항
|
||||||
|
|
||||||
|
### 관련 서비스
|
||||||
|
|
||||||
|
- `DDLExecutionService` - DDL 실행 (이미 전환 완료)
|
||||||
|
- `DDLSafetyValidator` - DDL 안전성 검증
|
||||||
|
|
||||||
|
### 의존성
|
||||||
|
|
||||||
|
- `../database/db` - query, queryOne 함수
|
||||||
|
- `../types/ddl` - DDL 관련 타입
|
||||||
|
- `../utils/logger` - 로깅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: 통계 쿼리, JSON 필드, 동적 WHERE 조건 포함
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
# 📋 Phase 3.12: ExternalCallConfigService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
ExternalCallConfigService는 **8개의 Prisma 호출**이 있으며, 외부 API 호출 설정 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | -------------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/externalCallConfigService.ts` |
|
||||||
|
| 파일 크기 | 612 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **8/8 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 중간 (JSON 필드, 복잡한 CRUD) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.12) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **8개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ 외부 호출 설정 CRUD 기능 정상 동작
|
||||||
|
- ⏳ JSON 필드 처리 (headers, params, auth_config)
|
||||||
|
- ⏳ 동적 WHERE 조건 생성
|
||||||
|
- ⏳ 민감 정보 암호화/복호화 유지
|
||||||
|
- ⏳ TypeScript 컴파일 성공
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 예상 Prisma 사용 패턴
|
||||||
|
|
||||||
|
### 주요 기능 (8개 예상)
|
||||||
|
|
||||||
|
#### 1. **외부 호출 설정 목록 조회**
|
||||||
|
|
||||||
|
- findMany with filters
|
||||||
|
- 페이징, 정렬
|
||||||
|
- 동적 WHERE 조건 (is_active, company_code, search)
|
||||||
|
|
||||||
|
#### 2. **외부 호출 설정 단건 조회**
|
||||||
|
|
||||||
|
- findUnique or findFirst
|
||||||
|
- config_id 기준
|
||||||
|
|
||||||
|
#### 3. **외부 호출 설정 생성**
|
||||||
|
|
||||||
|
- create
|
||||||
|
- JSON 필드 처리 (headers, params, auth_config)
|
||||||
|
- 민감 정보 암호화
|
||||||
|
|
||||||
|
#### 4. **외부 호출 설정 수정**
|
||||||
|
|
||||||
|
- update
|
||||||
|
- 동적 UPDATE 쿼리
|
||||||
|
- JSON 필드 업데이트
|
||||||
|
|
||||||
|
#### 5. **외부 호출 설정 삭제**
|
||||||
|
|
||||||
|
- delete or soft delete
|
||||||
|
|
||||||
|
#### 6. **외부 호출 설정 복제**
|
||||||
|
|
||||||
|
- findUnique + create
|
||||||
|
|
||||||
|
#### 7. **외부 호출 설정 테스트**
|
||||||
|
|
||||||
|
- findUnique
|
||||||
|
- 실제 HTTP 호출
|
||||||
|
|
||||||
|
#### 8. **외부 호출 이력 조회**
|
||||||
|
|
||||||
|
- findMany with 관계 조인
|
||||||
|
- 통계 쿼리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 전환 전략
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD 전환 (5개)
|
||||||
|
|
||||||
|
- getExternalCallConfigs() - 목록 조회
|
||||||
|
- getExternalCallConfig() - 단건 조회
|
||||||
|
- createExternalCallConfig() - 생성
|
||||||
|
- updateExternalCallConfig() - 수정
|
||||||
|
- deleteExternalCallConfig() - 삭제
|
||||||
|
|
||||||
|
### 2단계: 추가 기능 전환 (3개)
|
||||||
|
|
||||||
|
- duplicateExternalCallConfig() - 복제
|
||||||
|
- testExternalCallConfig() - 테스트
|
||||||
|
- getExternalCallHistory() - 이력 조회
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 목록 조회 (동적 WHERE + JSON)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const configs = await prisma.external_call_configs.findMany({
|
||||||
|
where: {
|
||||||
|
company_code: companyCode,
|
||||||
|
is_active: isActive,
|
||||||
|
OR: [
|
||||||
|
{ config_name: { contains: search, mode: "insensitive" } },
|
||||||
|
{ endpoint_url: { contains: search, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
conditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
`(config_name ILIKE $${paramIndex} OR endpoint_url ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configs = await query<any>(
|
||||||
|
`SELECT * FROM external_call_configs
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
[...params, limit, skip]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: JSON 필드 생성
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = await prisma.external_call_configs.create({
|
||||||
|
data: {
|
||||||
|
config_name: data.config_name,
|
||||||
|
endpoint_url: data.endpoint_url,
|
||||||
|
http_method: data.http_method,
|
||||||
|
headers: data.headers, // JSON
|
||||||
|
params: data.params, // JSON
|
||||||
|
auth_config: encryptedAuthConfig, // JSON (암호화됨)
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const config = await queryOne<any>(
|
||||||
|
`INSERT INTO external_call_configs
|
||||||
|
(config_name, endpoint_url, http_method, headers, params,
|
||||||
|
auth_config, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.config_name,
|
||||||
|
data.endpoint_url,
|
||||||
|
data.http_method,
|
||||||
|
JSON.stringify(data.headers),
|
||||||
|
JSON.stringify(data.params),
|
||||||
|
JSON.stringify(encryptedAuthConfig),
|
||||||
|
companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 동적 UPDATE (JSON 포함)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.headers) updateData.headers = data.headers;
|
||||||
|
if (data.params) updateData.params = data.params;
|
||||||
|
|
||||||
|
const config = await prisma.external_call_configs.update({
|
||||||
|
where: { config_id: configId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updateFields: string[] = ["updated_at = NOW()"];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.headers !== undefined) {
|
||||||
|
updateFields.push(`headers = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(data.headers));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.params !== undefined) {
|
||||||
|
updateFields.push(`params = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(data.params));
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await queryOne<any>(
|
||||||
|
`UPDATE external_call_configs
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE config_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, configId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. JSON 필드 처리
|
||||||
|
|
||||||
|
3개의 JSON 필드가 있을 것으로 예상:
|
||||||
|
|
||||||
|
- `headers` - HTTP 헤더
|
||||||
|
- `params` - 쿼리 파라미터
|
||||||
|
- `auth_config` - 인증 설정 (암호화됨)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// INSERT/UPDATE 시
|
||||||
|
JSON.stringify(jsonData);
|
||||||
|
|
||||||
|
// SELECT 후
|
||||||
|
const parsedData =
|
||||||
|
typeof row.headers === "string" ? JSON.parse(row.headers) : row.headers;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 민감 정보 암호화
|
||||||
|
|
||||||
|
auth_config는 암호화되어 저장되므로, 기존 암호화/복호화 로직 유지:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { encrypt, decrypt } from "../utils/encryption";
|
||||||
|
|
||||||
|
// 저장 시
|
||||||
|
const encryptedAuthConfig = encrypt(JSON.stringify(authConfig));
|
||||||
|
|
||||||
|
// 조회 시
|
||||||
|
const decryptedAuthConfig = JSON.parse(decrypt(row.auth_config));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. HTTP 메소드 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const VALID_HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
||||||
|
if (!VALID_HTTP_METHODS.includes(httpMethod)) {
|
||||||
|
throw new Error("Invalid HTTP method");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. URL 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
new URL(endpointUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid endpoint URL");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (8개)
|
||||||
|
|
||||||
|
1. **`getConfigs()`** - 목록 조회 (findMany → query)
|
||||||
|
2. **`getConfigById()`** - 단건 조회 (findUnique → queryOne)
|
||||||
|
3. **`createConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||||
|
4. **`createConfig()`** - 생성 (create → queryOne with INSERT)
|
||||||
|
5. **`updateConfig()`** - 중복 검사 (findFirst → queryOne)
|
||||||
|
6. **`updateConfig()`** - 수정 (update → queryOne with 동적 UPDATE)
|
||||||
|
7. **`deleteConfig()`** - 삭제 (update → query)
|
||||||
|
8. **`getExternalCallConfigsForButtonControl()`** - 조회 (findMany → query)
|
||||||
|
|
||||||
|
### 주요 기술적 개선사항
|
||||||
|
|
||||||
|
- 동적 WHERE 조건 생성 (company_code, call_type, api_type, is_active, search)
|
||||||
|
- ILIKE를 활용한 대소문자 구분 없는 검색
|
||||||
|
- 동적 UPDATE 쿼리 (9개 필드)
|
||||||
|
- JSON 필드 처리 (`config_data` → `JSON.stringify()`)
|
||||||
|
- 중복 검사 로직 유지
|
||||||
|
|
||||||
|
### 코드 정리
|
||||||
|
|
||||||
|
- [x] import 문 수정 완료
|
||||||
|
- [x] Prisma import 완전 제거
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] Linter 오류 없음
|
||||||
|
|
||||||
|
## 📝 원본 전환 체크리스트
|
||||||
|
|
||||||
|
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||||
|
|
||||||
|
- [ ] `getExternalCallConfigs()` - 목록 조회 (findMany + count)
|
||||||
|
- [ ] `getExternalCallConfig()` - 단건 조회 (findUnique)
|
||||||
|
- [ ] `createExternalCallConfig()` - 생성 (create)
|
||||||
|
- [ ] `updateExternalCallConfig()` - 수정 (update)
|
||||||
|
- [ ] `deleteExternalCallConfig()` - 삭제 (delete)
|
||||||
|
- [ ] `duplicateExternalCallConfig()` - 복제 (findUnique + create)
|
||||||
|
- [ ] `testExternalCallConfig()` - 테스트 (findUnique)
|
||||||
|
- [ ] `getExternalCallHistory()` - 이력 조회 (findMany)
|
||||||
|
|
||||||
|
### 2단계: 코드 정리
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] JSON 필드 처리 확인
|
||||||
|
- [ ] 암호화/복호화 로직 유지
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
|
||||||
|
### 3단계: 테스트
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (8개)
|
||||||
|
- [ ] 통합 테스트 작성 (3개)
|
||||||
|
- [ ] 암호화 테스트
|
||||||
|
- [ ] HTTP 호출 테스트
|
||||||
|
|
||||||
|
### 4단계: 문서화
|
||||||
|
|
||||||
|
- [ ] 전환 완료 문서 업데이트
|
||||||
|
- [ ] API 문서 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐ (중간)
|
||||||
|
- JSON 필드 처리
|
||||||
|
- 암호화/복호화 로직
|
||||||
|
- HTTP 호출 테스트
|
||||||
|
- **예상 소요 시간**: 1~1.5시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: JSON 필드, 민감 정보 암호화, HTTP 호출 포함
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
# 📋 Phase 3.13: EntityJoinService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
EntityJoinService는 **5개의 Prisma 호출**이 있으며, 엔티티 간 조인 관계 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------ |
|
||||||
|
| 파일 위치 | `backend-node/src/services/entityJoinService.ts` |
|
||||||
|
| 파일 크기 | 575 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 중간 (조인 쿼리, 관계 설정) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.13) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ 엔티티 조인 설정 CRUD 기능 정상 동작
|
||||||
|
- ⏳ 복잡한 조인 쿼리 전환 (LEFT JOIN, INNER JOIN)
|
||||||
|
- ⏳ 조인 유효성 검증
|
||||||
|
- ⏳ TypeScript 컴파일 성공
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 예상 Prisma 사용 패턴
|
||||||
|
|
||||||
|
### 주요 기능 (5개 예상)
|
||||||
|
|
||||||
|
#### 1. **엔티티 조인 목록 조회**
|
||||||
|
|
||||||
|
- findMany with filters
|
||||||
|
- 동적 WHERE 조건
|
||||||
|
- 페이징, 정렬
|
||||||
|
|
||||||
|
#### 2. **엔티티 조인 단건 조회**
|
||||||
|
|
||||||
|
- findUnique or findFirst
|
||||||
|
- join_id 기준
|
||||||
|
|
||||||
|
#### 3. **엔티티 조인 생성**
|
||||||
|
|
||||||
|
- create
|
||||||
|
- 조인 유효성 검증
|
||||||
|
|
||||||
|
#### 4. **엔티티 조인 수정**
|
||||||
|
|
||||||
|
- update
|
||||||
|
- 동적 UPDATE 쿼리
|
||||||
|
|
||||||
|
#### 5. **엔티티 조인 삭제**
|
||||||
|
|
||||||
|
- delete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 전환 전략
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD 전환 (5개)
|
||||||
|
|
||||||
|
- getEntityJoins() - 목록 조회
|
||||||
|
- getEntityJoin() - 단건 조회
|
||||||
|
- createEntityJoin() - 생성
|
||||||
|
- updateEntityJoin() - 수정
|
||||||
|
- deleteEntityJoin() - 삭제
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 조인 설정 조회 (LEFT JOIN으로 테이블 정보 포함)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const joins = await prisma.entity_joins.findMany({
|
||||||
|
where: {
|
||||||
|
company_code: companyCode,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
source_table: true,
|
||||||
|
target_table: true,
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const joins = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
ej.*,
|
||||||
|
st.table_name as source_table_name,
|
||||||
|
st.table_label as source_table_label,
|
||||||
|
tt.table_name as target_table_name,
|
||||||
|
tt.table_label as target_table_label
|
||||||
|
FROM entity_joins ej
|
||||||
|
LEFT JOIN tables st ON ej.source_table_id = st.table_id
|
||||||
|
LEFT JOIN tables tt ON ej.target_table_id = tt.table_id
|
||||||
|
WHERE ej.company_code = $1 AND ej.is_active = $2
|
||||||
|
ORDER BY ej.created_at DESC`,
|
||||||
|
[companyCode, true]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 조인 생성 (유효성 검증 포함)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 조인 유효성 검증
|
||||||
|
const sourceTable = await prisma.tables.findUnique({
|
||||||
|
where: { table_id: sourceTableId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetTable = await prisma.tables.findUnique({
|
||||||
|
where: { table_id: targetTableId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sourceTable || !targetTable) {
|
||||||
|
throw new Error("Invalid table references");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조인 생성
|
||||||
|
const join = await prisma.entity_joins.create({
|
||||||
|
data: {
|
||||||
|
source_table_id: sourceTableId,
|
||||||
|
target_table_id: targetTableId,
|
||||||
|
join_type: joinType,
|
||||||
|
join_condition: joinCondition,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 조인 유효성 검증 (Promise.all로 병렬 실행)
|
||||||
|
const [sourceTable, targetTable] = await Promise.all([
|
||||||
|
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [sourceTableId]),
|
||||||
|
queryOne<any>(`SELECT * FROM tables WHERE table_id = $1`, [targetTableId]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!sourceTable || !targetTable) {
|
||||||
|
throw new Error("Invalid table references");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조인 생성
|
||||||
|
const join = await queryOne<any>(
|
||||||
|
`INSERT INTO entity_joins
|
||||||
|
(source_table_id, target_table_id, join_type, join_condition,
|
||||||
|
company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[sourceTableId, targetTableId, joinType, joinCondition, companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 조인 수정
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const join = await prisma.entity_joins.update({
|
||||||
|
where: { join_id: joinId },
|
||||||
|
data: {
|
||||||
|
join_type: joinType,
|
||||||
|
join_condition: joinCondition,
|
||||||
|
is_active: isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updateFields: string[] = ["updated_at = NOW()"];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (joinType !== undefined) {
|
||||||
|
updateFields.push(`join_type = $${paramIndex++}`);
|
||||||
|
values.push(joinType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joinCondition !== undefined) {
|
||||||
|
updateFields.push(`join_condition = $${paramIndex++}`);
|
||||||
|
values.push(joinCondition);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
const join = await queryOne<any>(
|
||||||
|
`UPDATE entity_joins
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE join_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, joinId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. 조인 타입 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const VALID_JOIN_TYPES = ["INNER", "LEFT", "RIGHT", "FULL"];
|
||||||
|
if (!VALID_JOIN_TYPES.includes(joinType)) {
|
||||||
|
throw new Error("Invalid join type");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 조인 조건 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 조인 조건은 SQL 조건식 형태 (예: "source.id = target.parent_id")
|
||||||
|
// SQL 인젝션 방지를 위한 검증 필요
|
||||||
|
const isValidJoinCondition = /^[\w\s.=<>]+$/.test(joinCondition);
|
||||||
|
if (!isValidJoinCondition) {
|
||||||
|
throw new Error("Invalid join condition");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 순환 참조 방지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 조인이 순환 참조를 만들지 않는지 검증
|
||||||
|
async function checkCircularReference(
|
||||||
|
sourceTableId: number,
|
||||||
|
targetTableId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
// 재귀적으로 조인 관계 확인
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. LEFT JOIN으로 관련 테이블 정보 조회
|
||||||
|
|
||||||
|
조인 설정 조회 시 source/target 테이블 정보를 함께 가져오기 위해 LEFT JOIN 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (5개)
|
||||||
|
|
||||||
|
1. **`detectEntityJoins()`** - 엔티티 컬럼 감지 (findMany → query)
|
||||||
|
|
||||||
|
- column_labels 조회
|
||||||
|
- web_type = 'entity' 필터
|
||||||
|
- reference_table/reference_column IS NOT NULL
|
||||||
|
|
||||||
|
2. **`validateJoinConfig()`** - 테이블 존재 확인 ($queryRaw → query)
|
||||||
|
|
||||||
|
- information_schema.tables 조회
|
||||||
|
- 참조 테이블 검증
|
||||||
|
|
||||||
|
3. **`validateJoinConfig()`** - 컬럼 존재 확인 ($queryRaw → query)
|
||||||
|
|
||||||
|
- information_schema.columns 조회
|
||||||
|
- 표시 컬럼 검증
|
||||||
|
|
||||||
|
4. **`getReferenceTableColumns()`** - 컬럼 정보 조회 ($queryRaw → query)
|
||||||
|
|
||||||
|
- information_schema.columns 조회
|
||||||
|
- 문자열 타입 컬럼만 필터
|
||||||
|
|
||||||
|
5. **`getReferenceTableColumns()`** - 라벨 정보 조회 (findMany → query)
|
||||||
|
- column_labels 조회
|
||||||
|
- 컬럼명과 라벨 매핑
|
||||||
|
|
||||||
|
### 주요 기술적 개선사항
|
||||||
|
|
||||||
|
- **information_schema 쿼리**: 파라미터 바인딩으로 변경 ($1, $2)
|
||||||
|
- **타입 안전성**: 명확한 반환 타입 지정
|
||||||
|
- **IS NOT NULL 조건**: Prisma의 { not: null } → IS NOT NULL
|
||||||
|
- **IN 조건**: 여러 데이터 타입 필터링
|
||||||
|
|
||||||
|
### 코드 정리
|
||||||
|
|
||||||
|
- [x] PrismaClient import 제거
|
||||||
|
- [x] import 문 수정 완료
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] Linter 오류 없음
|
||||||
|
|
||||||
|
## 📝 원본 전환 체크리스트
|
||||||
|
|
||||||
|
### 1단계: Prisma 호출 전환 (✅ 완료)
|
||||||
|
|
||||||
|
- [ ] `getEntityJoins()` - 목록 조회 (findMany with include)
|
||||||
|
- [ ] `getEntityJoin()` - 단건 조회 (findUnique)
|
||||||
|
- [ ] `createEntityJoin()` - 생성 (create with validation)
|
||||||
|
- [ ] `updateEntityJoin()` - 수정 (update)
|
||||||
|
- [ ] `deleteEntityJoin()` - 삭제 (delete)
|
||||||
|
|
||||||
|
### 2단계: 코드 정리
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] 조인 유효성 검증 로직 유지
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
|
||||||
|
### 3단계: 테스트
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (5개)
|
||||||
|
- [ ] 조인 유효성 검증 테스트
|
||||||
|
- [ ] 순환 참조 방지 테스트
|
||||||
|
- [ ] 통합 테스트 작성 (2개)
|
||||||
|
|
||||||
|
### 4단계: 문서화
|
||||||
|
|
||||||
|
- [ ] 전환 완료 문서 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐ (중간)
|
||||||
|
- LEFT JOIN 쿼리
|
||||||
|
- 조인 유효성 검증
|
||||||
|
- 순환 참조 방지
|
||||||
|
- **예상 소요 시간**: 1시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: LEFT JOIN, 조인 유효성 검증, 순환 참조 방지 포함
|
||||||
|
|
@ -0,0 +1,456 @@
|
||||||
|
# 📋 Phase 3.14: AuthService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
AuthService는 **5개의 Prisma 호출**이 있으며, 사용자 인증 및 권한 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------ |
|
||||||
|
| 파일 위치 | `backend-node/src/services/authService.ts` |
|
||||||
|
| 파일 크기 | 335 라인 |
|
||||||
|
| Prisma 호출 | 0개 (이미 Phase 1.5에서 전환 완료) |
|
||||||
|
| **현재 진행률** | **5/5 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 높음 (보안, 암호화, 세션 관리) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.14) |
|
||||||
|
| **상태** | ✅ **완료** (Phase 1.5에서 이미 완료) |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **5개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ 사용자 인증 기능 정상 동작
|
||||||
|
- ⏳ 비밀번호 암호화/검증 유지
|
||||||
|
- ⏳ 세션 관리 기능 유지
|
||||||
|
- ⏳ 권한 검증 기능 유지
|
||||||
|
- ⏳ TypeScript 컴파일 성공
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 예상 Prisma 사용 패턴
|
||||||
|
|
||||||
|
### 주요 기능 (5개 예상)
|
||||||
|
|
||||||
|
#### 1. **사용자 로그인 (인증)**
|
||||||
|
|
||||||
|
- findFirst or findUnique
|
||||||
|
- 이메일/사용자명으로 조회
|
||||||
|
- 비밀번호 검증
|
||||||
|
|
||||||
|
#### 2. **사용자 정보 조회**
|
||||||
|
|
||||||
|
- findUnique
|
||||||
|
- user_id 기준
|
||||||
|
- 권한 정보 포함
|
||||||
|
|
||||||
|
#### 3. **사용자 생성 (회원가입)**
|
||||||
|
|
||||||
|
- create
|
||||||
|
- 비밀번호 암호화
|
||||||
|
- 중복 검사
|
||||||
|
|
||||||
|
#### 4. **비밀번호 변경**
|
||||||
|
|
||||||
|
- update
|
||||||
|
- 기존 비밀번호 검증
|
||||||
|
- 새 비밀번호 암호화
|
||||||
|
|
||||||
|
#### 5. **세션 관리**
|
||||||
|
|
||||||
|
- create, update, delete
|
||||||
|
- 세션 토큰 저장/조회
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 전환 전략
|
||||||
|
|
||||||
|
### 1단계: 인증 관련 전환 (2개)
|
||||||
|
|
||||||
|
- login() - 사용자 조회 + 비밀번호 검증
|
||||||
|
- getUserInfo() - 사용자 정보 조회
|
||||||
|
|
||||||
|
### 2단계: 사용자 관리 전환 (2개)
|
||||||
|
|
||||||
|
- createUser() - 사용자 생성
|
||||||
|
- changePassword() - 비밀번호 변경
|
||||||
|
|
||||||
|
### 3단계: 세션 관리 전환 (1개)
|
||||||
|
|
||||||
|
- manageSession() - 세션 CRUD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 로그인 (비밀번호 검증)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const user = await prisma.users.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: username },
|
||||||
|
{ email: username },
|
||||||
|
],
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new Error("Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const user = await queryOne<any>(
|
||||||
|
`SELECT * FROM users
|
||||||
|
WHERE (username = $1 OR email = $1)
|
||||||
|
AND is_active = $2`,
|
||||||
|
[username, true]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new Error("Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 사용자 생성 (비밀번호 암호화)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createUser(userData: CreateUserDto) {
|
||||||
|
// 중복 검사
|
||||||
|
const existing = await prisma.users.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ username: userData.username },
|
||||||
|
{ email: userData.email },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("User already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 암호화
|
||||||
|
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||||
|
|
||||||
|
// 사용자 생성
|
||||||
|
const user = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: userData.username,
|
||||||
|
email: userData.email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
company_code: userData.company_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createUser(userData: CreateUserDto) {
|
||||||
|
// 중복 검사
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
`SELECT * FROM users
|
||||||
|
WHERE username = $1 OR email = $2`,
|
||||||
|
[userData.username, userData.email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("User already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 암호화
|
||||||
|
const passwordHash = await bcrypt.hash(userData.password, 10);
|
||||||
|
|
||||||
|
// 사용자 생성
|
||||||
|
const user = await queryOne<any>(
|
||||||
|
`INSERT INTO users
|
||||||
|
(username, email, password_hash, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[userData.username, userData.email, passwordHash, userData.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 비밀번호 변경
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async changePassword(
|
||||||
|
userId: number,
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
) {
|
||||||
|
const user = await prisma.users.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOldPasswordValid = await bcrypt.compare(
|
||||||
|
oldPassword,
|
||||||
|
user.password_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new Error("Invalid old password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
await prisma.users.update({
|
||||||
|
where: { user_id: userId },
|
||||||
|
data: { password_hash: newPasswordHash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async changePassword(
|
||||||
|
userId: number,
|
||||||
|
oldPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
) {
|
||||||
|
const user = await queryOne<any>(
|
||||||
|
`SELECT * FROM users WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOldPasswordValid = await bcrypt.compare(
|
||||||
|
oldPassword,
|
||||||
|
user.password_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOldPasswordValid) {
|
||||||
|
throw new Error("Invalid old password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users
|
||||||
|
SET password_hash = $1, updated_at = NOW()
|
||||||
|
WHERE user_id = $2`,
|
||||||
|
[newPasswordHash, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. 비밀번호 보안
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
|
// 비밀번호 해싱 (회원가입, 비밀번호 변경)
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
|
// 비밀번호 검증 (로그인)
|
||||||
|
const isValid = await bcrypt.compare(plainPassword, passwordHash);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. SQL 인젝션 방지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 위험: 직접 문자열 결합
|
||||||
|
const sql = `SELECT * FROM users WHERE username = '${username}'`;
|
||||||
|
|
||||||
|
// ✅ 안전: 파라미터 바인딩
|
||||||
|
const user = await queryOne(`SELECT * FROM users WHERE username = $1`, [
|
||||||
|
username,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 세션 토큰 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// 세션 토큰 생성
|
||||||
|
const sessionToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// 세션 저장
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_sessions (user_id, session_token, expires_at)
|
||||||
|
VALUES ($1, $2, NOW() + INTERVAL '1 day')`,
|
||||||
|
[userId, sessionToken]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 권한 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async checkPermission(userId: number, permission: string): Promise<boolean> {
|
||||||
|
const result = await queryOne<{ has_permission: boolean }>(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM user_permissions up
|
||||||
|
JOIN permissions p ON up.permission_id = p.permission_id
|
||||||
|
WHERE up.user_id = $1 AND p.permission_name = $2
|
||||||
|
) as has_permission`,
|
||||||
|
[userId, permission]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result?.has_permission || false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역 (Phase 1.5에서 이미 완료됨)
|
||||||
|
|
||||||
|
AuthService는 Phase 1.5에서 이미 Raw Query로 전환이 완료되었습니다.
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (5개)
|
||||||
|
|
||||||
|
1. **`loginPwdCheck()`** - 로그인 비밀번호 검증
|
||||||
|
|
||||||
|
- user_info 테이블에서 비밀번호 조회
|
||||||
|
- EncryptUtil을 활용한 비밀번호 검증
|
||||||
|
- 마스터 패스워드 지원
|
||||||
|
|
||||||
|
2. **`insertLoginAccessLog()`** - 로그인 로그 기록
|
||||||
|
|
||||||
|
- login_access_log 테이블에 INSERT
|
||||||
|
- 로그인 시간, IP 주소 등 기록
|
||||||
|
|
||||||
|
3. **`getUserInfo()`** - 사용자 정보 조회
|
||||||
|
|
||||||
|
- user_info 테이블 조회
|
||||||
|
- PersonBean 객체로 반환
|
||||||
|
|
||||||
|
4. **`updateLastLoginDate()`** - 마지막 로그인 시간 업데이트
|
||||||
|
|
||||||
|
- user_info 테이블 UPDATE
|
||||||
|
- last_login_date 갱신
|
||||||
|
|
||||||
|
5. **`checkUserPermission()`** - 사용자 권한 확인
|
||||||
|
- user_auth 테이블 조회
|
||||||
|
- 권한 코드 검증
|
||||||
|
|
||||||
|
### 주요 기술적 특징
|
||||||
|
|
||||||
|
- **보안**: EncryptUtil을 활용한 안전한 비밀번호 검증
|
||||||
|
- **JWT 토큰**: JwtUtils를 활용한 토큰 생성 및 검증
|
||||||
|
- **로깅**: 상세한 로그인 이력 기록
|
||||||
|
- **에러 처리**: 안전한 에러 메시지 반환
|
||||||
|
|
||||||
|
### 코드 상태
|
||||||
|
|
||||||
|
- [x] Prisma import 없음
|
||||||
|
- [x] query 함수 사용 중
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] 보안 로직 유지
|
||||||
|
|
||||||
|
## 📝 원본 전환 체크리스트
|
||||||
|
|
||||||
|
### 1단계: Prisma 호출 전환 (✅ Phase 1.5에서 완료)
|
||||||
|
|
||||||
|
- [ ] `login()` - 사용자 조회 + 비밀번호 검증 (findFirst)
|
||||||
|
- [ ] `getUserInfo()` - 사용자 정보 조회 (findUnique)
|
||||||
|
- [ ] `createUser()` - 사용자 생성 (create with 중복 검사)
|
||||||
|
- [ ] `changePassword()` - 비밀번호 변경 (findUnique + update)
|
||||||
|
- [ ] `manageSession()` - 세션 관리 (create/update/delete)
|
||||||
|
|
||||||
|
### 2단계: 보안 검증
|
||||||
|
|
||||||
|
- [ ] 비밀번호 해싱 로직 유지 (bcrypt)
|
||||||
|
- [ ] SQL 인젝션 방지 확인
|
||||||
|
- [ ] 세션 토큰 보안 확인
|
||||||
|
- [ ] 중복 계정 방지 확인
|
||||||
|
|
||||||
|
### 3단계: 테스트
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (5개)
|
||||||
|
- [ ] 로그인 성공/실패 테스트
|
||||||
|
- [ ] 사용자 생성 테스트
|
||||||
|
- [ ] 비밀번호 변경 테스트
|
||||||
|
- [ ] 세션 관리 테스트
|
||||||
|
- [ ] 권한 검증 테스트
|
||||||
|
- [ ] 보안 테스트
|
||||||
|
- [ ] SQL 인젝션 테스트
|
||||||
|
- [ ] 비밀번호 강도 테스트
|
||||||
|
- [ ] 세션 탈취 방지 테스트
|
||||||
|
- [ ] 통합 테스트 작성 (2개)
|
||||||
|
|
||||||
|
### 4단계: 문서화
|
||||||
|
|
||||||
|
- [ ] 전환 완료 문서 업데이트
|
||||||
|
- [ ] 보안 가이드 업데이트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||||
|
- 보안 크리티컬 (비밀번호, 세션)
|
||||||
|
- SQL 인젝션 방지 필수
|
||||||
|
- 철저한 테스트 필요
|
||||||
|
- **예상 소요 시간**: 1.5~2시간
|
||||||
|
- Prisma 호출 전환: 40분
|
||||||
|
- 보안 검증: 40분
|
||||||
|
- 테스트: 40분
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 보안 필수 체크리스트
|
||||||
|
|
||||||
|
1. ✅ 모든 사용자 입력은 파라미터 바인딩 사용
|
||||||
|
2. ✅ 비밀번호는 절대 평문 저장 금지 (bcrypt 사용)
|
||||||
|
3. ✅ 세션 토큰은 충분히 길고 랜덤해야 함
|
||||||
|
4. ✅ 비밀번호 실패 시 구체적 오류 메시지 금지 ("User not found" vs "Invalid credentials")
|
||||||
|
5. ✅ 로그인 실패 횟수 제한 (Brute Force 방지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: 보안 크리티컬, 비밀번호 암호화, 세션 관리 포함
|
||||||
|
**⚠️ 주의**: 이 서비스는 보안에 매우 중요하므로 신중한 테스트 필수!
|
||||||
|
|
@ -0,0 +1,515 @@
|
||||||
|
# 📋 Phase 3.15: Batch Services Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
배치 관련 서비스들은 총 **24개의 Prisma 호출**이 있으며, 배치 작업 실행 및 관리를 담당합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ---------------------------------------------------------- |
|
||||||
|
| 대상 서비스 | 4개 (BatchExternalDb, ExecutionLog, Management, Scheduler) |
|
||||||
|
| 파일 위치 | `backend-node/src/services/batch*.ts` |
|
||||||
|
| 총 파일 크기 | 2,161 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **24/24 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 높음 (외부 DB 연동, 스케줄링, 트랜잭션) |
|
||||||
|
| 우선순위 | 🔴 높음 (Phase 3.15) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (24개)
|
||||||
|
|
||||||
|
#### 1. BatchExternalDbService (8개)
|
||||||
|
|
||||||
|
- `getAvailableConnections()` - findMany → query
|
||||||
|
- `getTables()` - $queryRaw → query (information_schema)
|
||||||
|
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||||
|
- `getExternalTables()` - findUnique → queryOne (x5)
|
||||||
|
|
||||||
|
#### 2. BatchExecutionLogService (7개)
|
||||||
|
|
||||||
|
- `getExecutionLogs()` - findMany + count → query (JOIN + 동적 WHERE)
|
||||||
|
- `createExecutionLog()` - create → queryOne (INSERT RETURNING)
|
||||||
|
- `updateExecutionLog()` - update → queryOne (동적 UPDATE)
|
||||||
|
- `deleteExecutionLog()` - delete → query
|
||||||
|
- `getLatestExecutionLog()` - findFirst → queryOne
|
||||||
|
- `getExecutionStats()` - findMany → query (동적 WHERE)
|
||||||
|
|
||||||
|
#### 3. BatchManagementService (5개)
|
||||||
|
|
||||||
|
- `getAvailableConnections()` - findMany → query
|
||||||
|
- `getTables()` - $queryRaw → query (information_schema)
|
||||||
|
- `getTableColumns()` - $queryRaw → query (information_schema)
|
||||||
|
- `getExternalTables()` - findUnique → queryOne (x2)
|
||||||
|
|
||||||
|
#### 4. BatchSchedulerService (4개)
|
||||||
|
|
||||||
|
- `loadActiveBatchConfigs()` - findMany → query (JOIN with json_agg)
|
||||||
|
- `updateBatchSchedule()` - findUnique → query (JOIN with json_agg)
|
||||||
|
- `getDataFromSource()` - $queryRawUnsafe → query
|
||||||
|
- `insertDataToTarget()` - $executeRawUnsafe → query
|
||||||
|
|
||||||
|
### 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **외부 DB 연결 조회 반복**
|
||||||
|
|
||||||
|
- 5개의 `findUnique` 호출을 `queryOne`으로 일괄 전환
|
||||||
|
- 암호화/복호화 로직 유지
|
||||||
|
|
||||||
|
2. **배치 설정 + 매핑 JOIN**
|
||||||
|
|
||||||
|
- Prisma `include` → `json_agg` + `json_build_object`
|
||||||
|
- `FILTER (WHERE bm.id IS NOT NULL)` 로 NULL 방지
|
||||||
|
- 계층적 JSON 데이터 생성
|
||||||
|
|
||||||
|
3. **동적 WHERE 절 생성**
|
||||||
|
|
||||||
|
- 조건부 필터링 (batch_config_id, execution_status, 날짜 범위)
|
||||||
|
- 파라미터 인덱스 동적 관리
|
||||||
|
|
||||||
|
4. **동적 UPDATE 쿼리**
|
||||||
|
|
||||||
|
- undefined 필드 제외
|
||||||
|
- 8개 필드의 조건부 업데이트
|
||||||
|
|
||||||
|
5. **통계 쿼리 전환**
|
||||||
|
- 클라이언트 사이드 집계 유지
|
||||||
|
- 원본 데이터만 쿼리로 조회
|
||||||
|
|
||||||
|
### 컴파일 상태
|
||||||
|
|
||||||
|
✅ TypeScript 컴파일 성공
|
||||||
|
✅ Linter 오류 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 서비스별 상세 분석
|
||||||
|
|
||||||
|
### 1. BatchExternalDbService (8개 호출, 943 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 외부 DB에서 배치 데이터 조회
|
||||||
|
- 외부 DB로 배치 데이터 저장
|
||||||
|
- 외부 DB 연결 관리
|
||||||
|
- 데이터 변환 및 매핑
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getExternalDbConnection()` - 외부 DB 연결 정보 조회
|
||||||
|
- `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||||
|
- `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||||
|
- `validateExternalDbConnection()` - 연결 검증
|
||||||
|
- `getExternalDbTables()` - 테이블 목록 조회
|
||||||
|
- `getExternalDbColumns()` - 컬럼 정보 조회
|
||||||
|
- `executeBatchQuery()` - 배치 쿼리 실행
|
||||||
|
- `getBatchExecutionStatus()` - 실행 상태 조회
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- 다양한 DB 타입 지원 (PostgreSQL, MySQL, Oracle, MSSQL)
|
||||||
|
- 연결 풀 관리
|
||||||
|
- 트랜잭션 처리
|
||||||
|
- 에러 핸들링 및 재시도
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. BatchExecutionLogService (7개 호출, 299 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 배치 실행 로그 생성
|
||||||
|
- 배치 실행 이력 조회
|
||||||
|
- 배치 실행 통계
|
||||||
|
- 로그 정리
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `createExecutionLog()` - 실행 로그 생성
|
||||||
|
- `updateExecutionLog()` - 실행 로그 업데이트
|
||||||
|
- `getExecutionLogs()` - 실행 로그 목록 조회
|
||||||
|
- `getExecutionLogById()` - 실행 로그 단건 조회
|
||||||
|
- `getExecutionStats()` - 실행 통계 조회
|
||||||
|
- `cleanupOldLogs()` - 오래된 로그 삭제
|
||||||
|
- `getFailedExecutions()` - 실패한 실행 조회
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- 대용량 로그 처리
|
||||||
|
- 통계 쿼리 최적화
|
||||||
|
- 로그 보관 정책
|
||||||
|
- 페이징 및 필터링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. BatchManagementService (5개 호출, 373 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 배치 작업 설정 관리
|
||||||
|
- 배치 작업 실행
|
||||||
|
- 배치 작업 중지
|
||||||
|
- 배치 작업 모니터링
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getBatchJobs()` - 배치 작업 목록 조회
|
||||||
|
- `getBatchJob()` - 배치 작업 단건 조회
|
||||||
|
- `createBatchJob()` - 배치 작업 생성
|
||||||
|
- `updateBatchJob()` - 배치 작업 수정
|
||||||
|
- `deleteBatchJob()` - 배치 작업 삭제
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- JSON 설정 필드 (job_config)
|
||||||
|
- 작업 상태 관리
|
||||||
|
- 동시 실행 제어
|
||||||
|
- 의존성 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. BatchSchedulerService (4개 호출, 546 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 배치 스케줄 설정
|
||||||
|
- Cron 표현식 관리
|
||||||
|
- 스케줄 실행
|
||||||
|
- 다음 실행 시간 계산
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getScheduledBatches()` - 스케줄된 배치 조회
|
||||||
|
- `createSchedule()` - 스케줄 생성
|
||||||
|
- `updateSchedule()` - 스케줄 수정
|
||||||
|
- `deleteSchedule()` - 스케줄 삭제
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- Cron 표현식 파싱
|
||||||
|
- 시간대 처리
|
||||||
|
- 실행 이력 추적
|
||||||
|
- 스케줄 충돌 방지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 통합 전환 전략
|
||||||
|
|
||||||
|
### Phase 1: 핵심 서비스 전환 (12개)
|
||||||
|
|
||||||
|
**BatchManagementService (5개) + BatchExecutionLogService (7개)**
|
||||||
|
|
||||||
|
- 배치 관리 및 로깅 기능 우선
|
||||||
|
- 상대적으로 단순한 CRUD
|
||||||
|
|
||||||
|
### Phase 2: 스케줄러 전환 (4개)
|
||||||
|
|
||||||
|
**BatchSchedulerService (4개)**
|
||||||
|
|
||||||
|
- 스케줄 관리
|
||||||
|
- Cron 표현식 처리
|
||||||
|
|
||||||
|
### Phase 3: 외부 DB 연동 전환 (8개)
|
||||||
|
|
||||||
|
**BatchExternalDbService (8개)**
|
||||||
|
|
||||||
|
- 가장 복잡한 서비스
|
||||||
|
- 외부 DB 연결 및 쿼리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 배치 실행 로그 생성
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const log = await prisma.batch_execution_logs.create({
|
||||||
|
data: {
|
||||||
|
batch_id: batchId,
|
||||||
|
status: "running",
|
||||||
|
started_at: new Date(),
|
||||||
|
execution_params: params,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const log = await queryOne<any>(
|
||||||
|
`INSERT INTO batch_execution_logs
|
||||||
|
(batch_id, status, started_at, execution_params, company_code)
|
||||||
|
VALUES ($1, $2, NOW(), $3, $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[batchId, "running", JSON.stringify(params), companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 배치 통계 조회
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stats = await prisma.batch_execution_logs.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where: {
|
||||||
|
batch_id: batchId,
|
||||||
|
started_at: { gte: startDate, lte: endDate },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stats = await query<{ status: string; count: string }>(
|
||||||
|
`SELECT status, COUNT(*) as count
|
||||||
|
FROM batch_execution_logs
|
||||||
|
WHERE batch_id = $1
|
||||||
|
AND started_at >= $2
|
||||||
|
AND started_at <= $3
|
||||||
|
GROUP BY status`,
|
||||||
|
[batchId, startDate, endDate]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 외부 DB 연결 및 쿼리
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await prisma.external_db_connections.findUnique({
|
||||||
|
where: { id: connectionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 DB 쿼리 실행 (Prisma 사용 불가, 이미 Raw Query일 가능성)
|
||||||
|
const externalData = await externalDbClient.query(sql);
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await queryOne<any>(
|
||||||
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 외부 DB 쿼리 실행 (기존 로직 유지)
|
||||||
|
const externalData = await externalDbClient.query(sql);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: 스케줄 관리
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const schedule = await prisma.batch_schedules.create({
|
||||||
|
data: {
|
||||||
|
batch_id: batchId,
|
||||||
|
cron_expression: cronExp,
|
||||||
|
is_active: true,
|
||||||
|
next_run_at: calculateNextRun(cronExp),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const nextRun = calculateNextRun(cronExp);
|
||||||
|
|
||||||
|
const schedule = await queryOne<any>(
|
||||||
|
`INSERT INTO batch_schedules
|
||||||
|
(batch_id, cron_expression, is_active, next_run_at, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[batchId, cronExp, true, nextRun]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. 외부 DB 연결 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DatabaseConnectorFactory } from "../database/connectorFactory";
|
||||||
|
|
||||||
|
// 외부 DB 연결 생성
|
||||||
|
const connector = DatabaseConnectorFactory.create(connection);
|
||||||
|
const externalClient = await connector.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 쿼리 실행
|
||||||
|
const result = await externalClient.query(sql, params);
|
||||||
|
} finally {
|
||||||
|
await connector.disconnect();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 트랜잭션 처리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await transaction(async (client) => {
|
||||||
|
// 배치 상태 업데이트
|
||||||
|
await client.query(`UPDATE batch_jobs SET status = $1 WHERE id = $2`, [
|
||||||
|
"running",
|
||||||
|
batchId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 실행 로그 생성
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO batch_execution_logs (batch_id, status, started_at)
|
||||||
|
VALUES ($1, $2, NOW())`,
|
||||||
|
[batchId, "running"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Cron 표현식 처리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import cron from "node-cron";
|
||||||
|
|
||||||
|
// Cron 표현식 검증
|
||||||
|
const isValid = cron.validate(cronExpression);
|
||||||
|
|
||||||
|
// 다음 실행 시간 계산
|
||||||
|
function calculateNextRun(cronExp: string): Date {
|
||||||
|
// Cron 파서를 사용하여 다음 실행 시간 계산
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 대용량 데이터 처리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 스트리밍 방식으로 대용량 데이터 처리
|
||||||
|
const stream = await query<any>(
|
||||||
|
`SELECT * FROM large_table WHERE batch_id = $1`,
|
||||||
|
[batchId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const row of stream) {
|
||||||
|
// 행 단위 처리
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 체크리스트
|
||||||
|
|
||||||
|
### BatchExternalDbService (8개)
|
||||||
|
|
||||||
|
- [ ] `getExternalDbConnection()` - 연결 정보 조회
|
||||||
|
- [ ] `fetchDataFromExternalDb()` - 외부 DB 데이터 조회
|
||||||
|
- [ ] `saveDataToExternalDb()` - 외부 DB 데이터 저장
|
||||||
|
- [ ] `validateExternalDbConnection()` - 연결 검증
|
||||||
|
- [ ] `getExternalDbTables()` - 테이블 목록 조회
|
||||||
|
- [ ] `getExternalDbColumns()` - 컬럼 정보 조회
|
||||||
|
- [ ] `executeBatchQuery()` - 배치 쿼리 실행
|
||||||
|
- [ ] `getBatchExecutionStatus()` - 실행 상태 조회
|
||||||
|
|
||||||
|
### BatchExecutionLogService (7개)
|
||||||
|
|
||||||
|
- [ ] `createExecutionLog()` - 실행 로그 생성
|
||||||
|
- [ ] `updateExecutionLog()` - 실행 로그 업데이트
|
||||||
|
- [ ] `getExecutionLogs()` - 실행 로그 목록 조회
|
||||||
|
- [ ] `getExecutionLogById()` - 실행 로그 단건 조회
|
||||||
|
- [ ] `getExecutionStats()` - 실행 통계 조회
|
||||||
|
- [ ] `cleanupOldLogs()` - 오래된 로그 삭제
|
||||||
|
- [ ] `getFailedExecutions()` - 실패한 실행 조회
|
||||||
|
|
||||||
|
### BatchManagementService (5개)
|
||||||
|
|
||||||
|
- [ ] `getBatchJobs()` - 배치 작업 목록 조회
|
||||||
|
- [ ] `getBatchJob()` - 배치 작업 단건 조회
|
||||||
|
- [ ] `createBatchJob()` - 배치 작업 생성
|
||||||
|
- [ ] `updateBatchJob()` - 배치 작업 수정
|
||||||
|
- [ ] `deleteBatchJob()` - 배치 작업 삭제
|
||||||
|
|
||||||
|
### BatchSchedulerService (4개)
|
||||||
|
|
||||||
|
- [ ] `getScheduledBatches()` - 스케줄된 배치 조회
|
||||||
|
- [ ] `createSchedule()` - 스케줄 생성
|
||||||
|
- [ ] `updateSchedule()` - 스케줄 수정
|
||||||
|
- [ ] `deleteSchedule()` - 스케줄 삭제
|
||||||
|
|
||||||
|
### 공통 작업
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (모든 서비스)
|
||||||
|
- [ ] Prisma import 완전 제거 (모든 서비스)
|
||||||
|
- [ ] 트랜잭션 로직 확인
|
||||||
|
- [ ] 에러 핸들링 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (24개)
|
||||||
|
|
||||||
|
- 각 Prisma 호출별 1개씩
|
||||||
|
|
||||||
|
### 통합 테스트 (8개)
|
||||||
|
|
||||||
|
- BatchExternalDbService: 외부 DB 연동 테스트 (2개)
|
||||||
|
- BatchExecutionLogService: 로그 생성 및 조회 테스트 (2개)
|
||||||
|
- BatchManagementService: 배치 작업 실행 테스트 (2개)
|
||||||
|
- BatchSchedulerService: 스케줄 실행 테스트 (2개)
|
||||||
|
|
||||||
|
### 성능 테스트
|
||||||
|
|
||||||
|
- 대용량 데이터 처리 성능
|
||||||
|
- 동시 배치 실행 성능
|
||||||
|
- 외부 DB 연결 풀 성능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐⭐⭐ (매우 높음)
|
||||||
|
- 외부 DB 연동
|
||||||
|
- 트랜잭션 처리
|
||||||
|
- 스케줄링 로직
|
||||||
|
- 대용량 데이터 처리
|
||||||
|
- **예상 소요 시간**: 4~5시간
|
||||||
|
- Phase 1 (BatchManagement + ExecutionLog): 1.5시간
|
||||||
|
- Phase 2 (Scheduler): 1시간
|
||||||
|
- Phase 3 (ExternalDb): 2시간
|
||||||
|
- 테스트 및 문서화: 0.5시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 중요 체크포인트
|
||||||
|
|
||||||
|
1. ✅ 외부 DB 연결은 반드시 try-finally에서 해제
|
||||||
|
2. ✅ 배치 실행 중 에러 시 롤백 처리
|
||||||
|
3. ✅ Cron 표현식 검증 필수
|
||||||
|
4. ✅ 대용량 데이터는 스트리밍 방식 사용
|
||||||
|
5. ✅ 동시 실행 제한 확인
|
||||||
|
|
||||||
|
### 성능 최적화
|
||||||
|
|
||||||
|
- 연결 풀 활용
|
||||||
|
- 배치 쿼리 최적화
|
||||||
|
- 인덱스 확인
|
||||||
|
- 불필요한 로그 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: 외부 DB 연동, 스케줄링, 트랜잭션 처리 포함
|
||||||
|
**⚠️ 주의**: 배치 시스템의 핵심 기능이므로 신중한 테스트 필수!
|
||||||
|
|
@ -0,0 +1,540 @@
|
||||||
|
# 📋 Phase 3.16: Data Management Services Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
데이터 관리 관련 서비스들은 총 **18개의 Prisma 호출**이 있으며, 동적 폼, 데이터 매핑, 데이터 서비스, 관리자 기능을 담당합니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ----------------------------------------------------- |
|
||||||
|
| 대상 서비스 | 4개 (EnhancedDynamicForm, DataMapping, Data, Admin) |
|
||||||
|
| 파일 위치 | `backend-node/src/services/{enhanced,data,admin}*.ts` |
|
||||||
|
| 총 파일 크기 | 2,062 라인 |
|
||||||
|
| Prisma 호출 | 0개 (전환 완료) |
|
||||||
|
| **현재 진행률** | **18/18 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 중간 (동적 쿼리, JSON 필드, 관리자 기능) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.16) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역
|
||||||
|
|
||||||
|
### 전환된 Prisma 호출 (18개)
|
||||||
|
|
||||||
|
#### 1. EnhancedDynamicFormService (6개)
|
||||||
|
|
||||||
|
- `validateTableExists()` - $queryRawUnsafe → query
|
||||||
|
- `getTableColumns()` - $queryRawUnsafe → query
|
||||||
|
- `getColumnWebTypes()` - $queryRawUnsafe → query
|
||||||
|
- `getPrimaryKeys()` - $queryRawUnsafe → query
|
||||||
|
- `performInsert()` - $queryRawUnsafe → query
|
||||||
|
- `performUpdate()` - $queryRawUnsafe → query
|
||||||
|
|
||||||
|
#### 2. DataMappingService (5개)
|
||||||
|
|
||||||
|
- `getSourceData()` - $queryRawUnsafe → query
|
||||||
|
- `executeInsert()` - $executeRawUnsafe → query
|
||||||
|
- `executeUpsert()` - $executeRawUnsafe → query
|
||||||
|
- `executeUpdate()` - $executeRawUnsafe → query
|
||||||
|
- `disconnect()` - 제거 (Raw Query는 disconnect 불필요)
|
||||||
|
|
||||||
|
#### 3. DataService (4개)
|
||||||
|
|
||||||
|
- `getTableData()` - $queryRawUnsafe → query
|
||||||
|
- `checkTableExists()` - $queryRawUnsafe → query
|
||||||
|
- `getTableColumnsSimple()` - $queryRawUnsafe → query
|
||||||
|
- `getColumnLabel()` - $queryRawUnsafe → query
|
||||||
|
|
||||||
|
#### 4. AdminService (3개)
|
||||||
|
|
||||||
|
- `getAdminMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||||
|
- `getUserMenuList()` - $queryRaw → query (WITH RECURSIVE)
|
||||||
|
- `getMenuInfo()` - findUnique → query (JOIN)
|
||||||
|
|
||||||
|
### 주요 기술적 해결 사항
|
||||||
|
|
||||||
|
1. **변수명 충돌 해결**
|
||||||
|
|
||||||
|
- `dataService.ts`에서 `query` 변수 → `sql` 변수로 변경
|
||||||
|
- `query()` 함수와 로컬 변수 충돌 방지
|
||||||
|
|
||||||
|
2. **WITH RECURSIVE 쿼리 전환**
|
||||||
|
|
||||||
|
- Prisma의 `$queryRaw` 템플릿 리터럴 → 일반 문자열
|
||||||
|
- `${userLang}` → `$1` 파라미터 바인딩
|
||||||
|
|
||||||
|
3. **JOIN 쿼리 전환**
|
||||||
|
|
||||||
|
- Prisma의 `include` 옵션 → `LEFT JOIN` 쿼리
|
||||||
|
- 관계 데이터를 단일 쿼리로 조회
|
||||||
|
|
||||||
|
4. **동적 쿼리 생성**
|
||||||
|
- 동적 WHERE 조건 구성
|
||||||
|
- SQL 인젝션 방지 (컬럼명 검증)
|
||||||
|
- 동적 ORDER BY 처리
|
||||||
|
|
||||||
|
### 컴파일 상태
|
||||||
|
|
||||||
|
✅ TypeScript 컴파일 성공
|
||||||
|
✅ Linter 오류 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 서비스별 상세 분석
|
||||||
|
|
||||||
|
### 1. EnhancedDynamicFormService (6개 호출, 786 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 고급 동적 폼 관리
|
||||||
|
- 폼 검증 규칙
|
||||||
|
- 조건부 필드 표시
|
||||||
|
- 폼 템플릿 관리
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getEnhancedForms()` - 고급 폼 목록 조회
|
||||||
|
- `getEnhancedForm()` - 고급 폼 단건 조회
|
||||||
|
- `createEnhancedForm()` - 고급 폼 생성
|
||||||
|
- `updateEnhancedForm()` - 고급 폼 수정
|
||||||
|
- `deleteEnhancedForm()` - 고급 폼 삭제
|
||||||
|
- `getFormValidationRules()` - 검증 규칙 조회
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- JSON 필드 (validation_rules, conditional_logic, field_config)
|
||||||
|
- 복잡한 검증 규칙
|
||||||
|
- 동적 필드 생성
|
||||||
|
- 조건부 표시 로직
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. DataMappingService (5개 호출, 575 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 데이터 매핑 설정 관리
|
||||||
|
- 소스-타겟 필드 매핑
|
||||||
|
- 데이터 변환 규칙
|
||||||
|
- 매핑 실행
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getDataMappings()` - 매핑 설정 목록 조회
|
||||||
|
- `getDataMapping()` - 매핑 설정 단건 조회
|
||||||
|
- `createDataMapping()` - 매핑 설정 생성
|
||||||
|
- `updateDataMapping()` - 매핑 설정 수정
|
||||||
|
- `deleteDataMapping()` - 매핑 설정 삭제
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- JSON 필드 (field_mappings, transformation_rules)
|
||||||
|
- 복잡한 변환 로직
|
||||||
|
- 매핑 검증
|
||||||
|
- 실행 이력 추적
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. DataService (4개 호출, 327 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 동적 데이터 조회
|
||||||
|
- 데이터 필터링
|
||||||
|
- 데이터 정렬
|
||||||
|
- 데이터 집계
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getDataByTable()` - 테이블별 데이터 조회
|
||||||
|
- `getDataById()` - 데이터 단건 조회
|
||||||
|
- `executeCustomQuery()` - 커스텀 쿼리 실행
|
||||||
|
- `getDataStatistics()` - 데이터 통계 조회
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- 동적 테이블 쿼리
|
||||||
|
- SQL 인젝션 방지
|
||||||
|
- 동적 WHERE 조건
|
||||||
|
- 집계 쿼리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. AdminService (3개 호출, 374 라인)
|
||||||
|
|
||||||
|
**주요 기능**:
|
||||||
|
|
||||||
|
- 관리자 메뉴 관리
|
||||||
|
- 시스템 설정
|
||||||
|
- 사용자 관리
|
||||||
|
- 로그 조회
|
||||||
|
|
||||||
|
**예상 Prisma 호출**:
|
||||||
|
|
||||||
|
- `getAdminMenus()` - 관리자 메뉴 조회
|
||||||
|
- `getSystemSettings()` - 시스템 설정 조회
|
||||||
|
- `updateSystemSettings()` - 시스템 설정 업데이트
|
||||||
|
|
||||||
|
**기술적 고려사항**:
|
||||||
|
|
||||||
|
- 메뉴 계층 구조
|
||||||
|
- 권한 기반 필터링
|
||||||
|
- JSON 설정 필드
|
||||||
|
- 캐싱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 통합 전환 전략
|
||||||
|
|
||||||
|
### Phase 1: 단순 CRUD 전환 (12개)
|
||||||
|
|
||||||
|
**EnhancedDynamicFormService (6개) + DataMappingService (5개) + AdminService (1개)**
|
||||||
|
|
||||||
|
- 기본 CRUD 기능
|
||||||
|
- JSON 필드 처리
|
||||||
|
|
||||||
|
### Phase 2: 동적 쿼리 전환 (4개)
|
||||||
|
|
||||||
|
**DataService (4개)**
|
||||||
|
|
||||||
|
- 동적 테이블 쿼리
|
||||||
|
- 보안 검증
|
||||||
|
|
||||||
|
### Phase 3: 고급 기능 전환 (2개)
|
||||||
|
|
||||||
|
**AdminService (2개)**
|
||||||
|
|
||||||
|
- 시스템 설정
|
||||||
|
- 캐싱
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 고급 폼 생성 (JSON 필드)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = await prisma.enhanced_forms.create({
|
||||||
|
data: {
|
||||||
|
form_code: formCode,
|
||||||
|
form_name: formName,
|
||||||
|
validation_rules: validationRules, // JSON
|
||||||
|
conditional_logic: conditionalLogic, // JSON
|
||||||
|
field_config: fieldConfig, // JSON
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const form = await queryOne<any>(
|
||||||
|
`INSERT INTO enhanced_forms
|
||||||
|
(form_code, form_name, validation_rules, conditional_logic,
|
||||||
|
field_config, company_code, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
formCode,
|
||||||
|
formName,
|
||||||
|
JSON.stringify(validationRules),
|
||||||
|
JSON.stringify(conditionalLogic),
|
||||||
|
JSON.stringify(fieldConfig),
|
||||||
|
companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 데이터 매핑 조회
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mappings = await prisma.data_mappings.findMany({
|
||||||
|
where: {
|
||||||
|
source_table: sourceTable,
|
||||||
|
target_table: targetTable,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
source_columns: true,
|
||||||
|
target_columns: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const mappings = await query<any>(
|
||||||
|
`SELECT
|
||||||
|
dm.*,
|
||||||
|
json_agg(DISTINCT jsonb_build_object(
|
||||||
|
'column_id', sc.column_id,
|
||||||
|
'column_name', sc.column_name
|
||||||
|
)) FILTER (WHERE sc.column_id IS NOT NULL) as source_columns,
|
||||||
|
json_agg(DISTINCT jsonb_build_object(
|
||||||
|
'column_id', tc.column_id,
|
||||||
|
'column_name', tc.column_name
|
||||||
|
)) FILTER (WHERE tc.column_id IS NOT NULL) as target_columns
|
||||||
|
FROM data_mappings dm
|
||||||
|
LEFT JOIN columns sc ON dm.mapping_id = sc.mapping_id AND sc.type = 'source'
|
||||||
|
LEFT JOIN columns tc ON dm.mapping_id = tc.mapping_id AND tc.type = 'target'
|
||||||
|
WHERE dm.source_table = $1
|
||||||
|
AND dm.target_table = $2
|
||||||
|
AND dm.is_active = $3
|
||||||
|
GROUP BY dm.mapping_id`,
|
||||||
|
[sourceTable, targetTable, true]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 동적 테이블 쿼리 (DataService)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Prisma로는 동적 테이블 쿼리 불가능
|
||||||
|
// 이미 $queryRawUnsafe 사용 중일 가능성
|
||||||
|
const data = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT * FROM ${tableName} WHERE ${whereClause}`,
|
||||||
|
...params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// SQL 인젝션 방지를 위한 테이블명 검증
|
||||||
|
const validTableName = validateTableName(tableName);
|
||||||
|
|
||||||
|
const data = await query<any>(
|
||||||
|
`SELECT * FROM ${validTableName} WHERE ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: 관리자 메뉴 조회 (계층 구조)
|
||||||
|
|
||||||
|
**변경 전**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const menus = await prisma.admin_menus.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
orderBy: { sort_order: "asc" },
|
||||||
|
include: {
|
||||||
|
children: {
|
||||||
|
orderBy: { sort_order: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 재귀 CTE를 사용한 계층 쿼리
|
||||||
|
const menus = await query<any>(
|
||||||
|
`WITH RECURSIVE menu_tree AS (
|
||||||
|
SELECT *, 0 as level, ARRAY[menu_id] as path
|
||||||
|
FROM admin_menus
|
||||||
|
WHERE parent_id IS NULL AND is_active = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT m.*, mt.level + 1, mt.path || m.menu_id
|
||||||
|
FROM admin_menus m
|
||||||
|
JOIN menu_tree mt ON m.parent_id = mt.menu_id
|
||||||
|
WHERE m.is_active = $1
|
||||||
|
)
|
||||||
|
SELECT * FROM menu_tree
|
||||||
|
ORDER BY path, sort_order`,
|
||||||
|
[true]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 기술적 고려사항
|
||||||
|
|
||||||
|
### 1. JSON 필드 처리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 복잡한 JSON 구조
|
||||||
|
interface ValidationRules {
|
||||||
|
required?: string[];
|
||||||
|
min?: Record<string, number>;
|
||||||
|
max?: Record<string, number>;
|
||||||
|
pattern?: Record<string, string>;
|
||||||
|
custom?: Array<{ field: string; rule: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 저장 시
|
||||||
|
JSON.stringify(validationRules);
|
||||||
|
|
||||||
|
// 조회 후
|
||||||
|
const parsed =
|
||||||
|
typeof row.validation_rules === "string"
|
||||||
|
? JSON.parse(row.validation_rules)
|
||||||
|
: row.validation_rules;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 동적 테이블 쿼리 보안
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 테이블명 화이트리스트
|
||||||
|
const ALLOWED_TABLES = ["users", "products", "orders"];
|
||||||
|
|
||||||
|
function validateTableName(tableName: string): string {
|
||||||
|
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||||
|
throw new Error("Invalid table name");
|
||||||
|
}
|
||||||
|
return tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼명 검증
|
||||||
|
function validateColumnName(columnName: string): string {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(columnName)) {
|
||||||
|
throw new Error("Invalid column name");
|
||||||
|
}
|
||||||
|
return columnName;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 재귀 CTE (계층 구조)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH RECURSIVE hierarchy AS (
|
||||||
|
-- 최상위 노드
|
||||||
|
SELECT * FROM table WHERE parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
-- 하위 노드
|
||||||
|
SELECT t.* FROM table t
|
||||||
|
JOIN hierarchy h ON t.parent_id = h.id
|
||||||
|
)
|
||||||
|
SELECT * FROM hierarchy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. JSON 집계 (관계 데이터)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
parent.*,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
jsonb_build_object('id', child.id, 'name', child.name)
|
||||||
|
) FILTER (WHERE child.id IS NOT NULL),
|
||||||
|
'[]'
|
||||||
|
) as children
|
||||||
|
FROM parent
|
||||||
|
LEFT JOIN child ON parent.id = child.parent_id
|
||||||
|
GROUP BY parent.id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 체크리스트
|
||||||
|
|
||||||
|
### EnhancedDynamicFormService (6개)
|
||||||
|
|
||||||
|
- [ ] `getEnhancedForms()` - 목록 조회
|
||||||
|
- [ ] `getEnhancedForm()` - 단건 조회
|
||||||
|
- [ ] `createEnhancedForm()` - 생성 (JSON 필드)
|
||||||
|
- [ ] `updateEnhancedForm()` - 수정 (JSON 필드)
|
||||||
|
- [ ] `deleteEnhancedForm()` - 삭제
|
||||||
|
- [ ] `getFormValidationRules()` - 검증 규칙 조회
|
||||||
|
|
||||||
|
### DataMappingService (5개)
|
||||||
|
|
||||||
|
- [ ] `getDataMappings()` - 목록 조회
|
||||||
|
- [ ] `getDataMapping()` - 단건 조회
|
||||||
|
- [ ] `createDataMapping()` - 생성
|
||||||
|
- [ ] `updateDataMapping()` - 수정
|
||||||
|
- [ ] `deleteDataMapping()` - 삭제
|
||||||
|
|
||||||
|
### DataService (4개)
|
||||||
|
|
||||||
|
- [ ] `getDataByTable()` - 동적 테이블 조회
|
||||||
|
- [ ] `getDataById()` - 단건 조회
|
||||||
|
- [ ] `executeCustomQuery()` - 커스텀 쿼리
|
||||||
|
- [ ] `getDataStatistics()` - 통계 조회
|
||||||
|
|
||||||
|
### AdminService (3개)
|
||||||
|
|
||||||
|
- [ ] `getAdminMenus()` - 메뉴 조회 (재귀 CTE)
|
||||||
|
- [ ] `getSystemSettings()` - 시스템 설정 조회
|
||||||
|
- [ ] `updateSystemSettings()` - 시스템 설정 업데이트
|
||||||
|
|
||||||
|
### 공통 작업
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (모든 서비스)
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
- [ ] JSON 필드 처리 확인
|
||||||
|
- [ ] 보안 검증 (SQL 인젝션)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 계획
|
||||||
|
|
||||||
|
### 단위 테스트 (18개)
|
||||||
|
|
||||||
|
- 각 Prisma 호출별 1개씩
|
||||||
|
|
||||||
|
### 통합 테스트 (6개)
|
||||||
|
|
||||||
|
- EnhancedDynamicFormService: 폼 생성 및 검증 테스트 (2개)
|
||||||
|
- DataMappingService: 매핑 설정 및 실행 테스트 (2개)
|
||||||
|
- DataService: 동적 쿼리 및 보안 테스트 (1개)
|
||||||
|
- AdminService: 메뉴 계층 구조 테스트 (1개)
|
||||||
|
|
||||||
|
### 보안 테스트
|
||||||
|
|
||||||
|
- SQL 인젝션 방지 테스트
|
||||||
|
- 테이블명 검증 테스트
|
||||||
|
- 컬럼명 검증 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 난이도 및 소요 시간
|
||||||
|
|
||||||
|
- **난이도**: ⭐⭐⭐⭐ (높음)
|
||||||
|
- JSON 필드 처리
|
||||||
|
- 동적 쿼리 보안
|
||||||
|
- 재귀 CTE
|
||||||
|
- JSON 집계
|
||||||
|
- **예상 소요 시간**: 2.5~3시간
|
||||||
|
- Phase 1 (기본 CRUD): 1시간
|
||||||
|
- Phase 2 (동적 쿼리): 1시간
|
||||||
|
- Phase 3 (고급 기능): 0.5시간
|
||||||
|
- 테스트 및 문서화: 0.5시간
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
### 보안 필수 체크리스트
|
||||||
|
|
||||||
|
1. ✅ 동적 테이블명은 반드시 화이트리스트 검증
|
||||||
|
2. ✅ 동적 컬럼명은 정규식으로 검증
|
||||||
|
3. ✅ WHERE 절 파라미터는 반드시 바인딩
|
||||||
|
4. ✅ JSON 필드는 파싱 에러 처리
|
||||||
|
5. ✅ 재귀 쿼리는 깊이 제한 설정
|
||||||
|
|
||||||
|
### 성능 최적화
|
||||||
|
|
||||||
|
- JSON 필드 인덱싱 (GIN 인덱스)
|
||||||
|
- 재귀 쿼리 깊이 제한
|
||||||
|
- 집계 쿼리 최적화
|
||||||
|
- 필요시 캐싱 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: JSON 필드, 동적 쿼리, 재귀 CTE, 보안 검증 포함
|
||||||
|
**⚠️ 주의**: 동적 쿼리는 SQL 인젝션 방지가 매우 중요!
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# 📋 Phase 3.17: ReferenceCacheService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
ReferenceCacheService는 **0개의 Prisma 호출**이 있으며, 참조 데이터 캐싱을 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ---------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/referenceCacheService.ts` |
|
||||||
|
| 파일 크기 | 499 라인 |
|
||||||
|
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||||
|
| **현재 진행률** | **3/3 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 낮음 (캐싱 로직) |
|
||||||
|
| 우선순위 | 🟢 낮음 (Phase 3.17) |
|
||||||
|
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||||
|
|
||||||
|
ReferenceCacheService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
|
||||||
|
1. **참조 데이터 캐싱**
|
||||||
|
|
||||||
|
- 자주 사용되는 참조 테이블 데이터를 메모리에 캐싱
|
||||||
|
- 성능 향상을 위한 캐시 전략
|
||||||
|
|
||||||
|
2. **캐시 관리**
|
||||||
|
|
||||||
|
- 캐시 갱신 로직
|
||||||
|
- TTL(Time To Live) 관리
|
||||||
|
- 캐시 무효화
|
||||||
|
|
||||||
|
3. **데이터 조회 최적화**
|
||||||
|
- 캐시 히트/미스 처리
|
||||||
|
- 백그라운드 갱신
|
||||||
|
|
||||||
|
### 기술적 특징
|
||||||
|
|
||||||
|
- **메모리 캐싱**: Map/Object 기반 인메모리 캐싱
|
||||||
|
- **성능 최적화**: 반복 DB 조회 최소화
|
||||||
|
- **자동 갱신**: 주기적 캐시 갱신 로직
|
||||||
|
|
||||||
|
### 코드 상태
|
||||||
|
|
||||||
|
- [x] Prisma import 없음
|
||||||
|
- [x] query 함수 사용 중
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] 캐싱 로직 정상 동작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 비고
|
||||||
|
|
||||||
|
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||||
|
|
||||||
|
**상태**: ✅ **완료**
|
||||||
|
**특이사항**: 캐싱 로직으로 성능에 중요한 서비스
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# 📋 Phase 3.18: DDLExecutionService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DDLExecutionService는 **0개의 Prisma 호출**이 있으며, DDL 실행 및 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | -------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/ddlExecutionService.ts` |
|
||||||
|
| 파일 크기 | 786 라인 |
|
||||||
|
| Prisma 호출 | 0개 (이미 전환 완료) |
|
||||||
|
| **현재 진행률** | **6/6 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 높음 (DDL 실행, 안전성 검증) |
|
||||||
|
| 우선순위 | 🔴 높음 (Phase 3.18) |
|
||||||
|
| **상태** | ✅ **완료** (이미 전환 완료됨) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 전환 완료 내역 (이미 완료됨)
|
||||||
|
|
||||||
|
DDLExecutionService는 이미 Raw Query로 전환이 완료되었습니다.
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
|
||||||
|
1. **테이블 생성 (CREATE TABLE)**
|
||||||
|
|
||||||
|
- 동적 테이블 생성
|
||||||
|
- 컬럼 정의 및 제약조건
|
||||||
|
- 인덱스 생성
|
||||||
|
|
||||||
|
2. **컬럼 추가 (ADD COLUMN)**
|
||||||
|
|
||||||
|
- 기존 테이블에 컬럼 추가
|
||||||
|
- 데이터 타입 검증
|
||||||
|
- 기본값 설정
|
||||||
|
|
||||||
|
3. **테이블/컬럼 삭제 (DROP)**
|
||||||
|
|
||||||
|
- 안전한 삭제 검증
|
||||||
|
- 의존성 체크
|
||||||
|
- 롤백 가능성
|
||||||
|
|
||||||
|
4. **DDL 안전성 검증**
|
||||||
|
|
||||||
|
- DDL 실행 전 검증
|
||||||
|
- 순환 참조 방지
|
||||||
|
- 데이터 손실 방지
|
||||||
|
|
||||||
|
5. **DDL 실행 이력**
|
||||||
|
|
||||||
|
- 모든 DDL 실행 기록
|
||||||
|
- 성공/실패 로그
|
||||||
|
- 롤백 정보
|
||||||
|
|
||||||
|
6. **트랜잭션 관리**
|
||||||
|
- DDL 트랜잭션 처리
|
||||||
|
- 에러 시 롤백
|
||||||
|
- 일관성 유지
|
||||||
|
|
||||||
|
### 기술적 특징
|
||||||
|
|
||||||
|
- **동적 DDL 생성**: 파라미터 기반 DDL 쿼리 생성
|
||||||
|
- **안전성 검증**: 실행 전 다중 검증 단계
|
||||||
|
- **감사 로깅**: DDLAuditLogger와 연동
|
||||||
|
- **PostgreSQL 특화**: PostgreSQL DDL 문법 활용
|
||||||
|
|
||||||
|
### 보안 및 안전성
|
||||||
|
|
||||||
|
- **SQL 인젝션 방지**: 테이블/컬럼명 화이트리스트 검증
|
||||||
|
- **권한 검증**: 사용자 권한 확인
|
||||||
|
- **백업 권장**: DDL 실행 전 백업 체크
|
||||||
|
- **복구 가능성**: 실행 이력 기록
|
||||||
|
|
||||||
|
### 코드 상태
|
||||||
|
|
||||||
|
- [x] Prisma import 없음
|
||||||
|
- [x] query 함수 사용 중
|
||||||
|
- [x] TypeScript 컴파일 성공
|
||||||
|
- [x] 안전성 검증 로직 유지
|
||||||
|
- [x] DDLAuditLogger 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 비고
|
||||||
|
|
||||||
|
이 서비스는 이미 Raw Query로 전환이 완료되어 있어 추가 작업이 필요하지 않습니다.
|
||||||
|
|
||||||
|
**상태**: ✅ **완료**
|
||||||
|
**특이사항**: DDL 실행의 핵심 서비스로 안전성이 매우 중요
|
||||||
|
**⚠️ 주의**: 프로덕션 환경에서 DDL 실행 시 각별한 주의 필요
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
# 🎨 Phase 3.7: LayoutService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
LayoutService는 **10개의 Prisma 호출**이 있으며, 레이아웃 표준 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | --------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/services/layoutService.ts` |
|
||||||
|
| 파일 크기 | 425+ 라인 |
|
||||||
|
| Prisma 호출 | 10개 |
|
||||||
|
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||||
|
| 복잡도 | 중간 (JSON 필드, 검색, 통계) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.7) |
|
||||||
|
| **상태** | ⏳ **대기 중** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ JSON 필드 처리 (layout_config, sections)
|
||||||
|
- ⏳ 복잡한 검색 조건 처리
|
||||||
|
- ⏳ GROUP BY 통계 쿼리 전환
|
||||||
|
- ⏳ 모든 단위 테스트 통과
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 Prisma 호출 (10개)
|
||||||
|
|
||||||
|
#### 1. **getLayouts()** - 레이아웃 목록 조회
|
||||||
|
```typescript
|
||||||
|
// Line 92, 102
|
||||||
|
const total = await prisma.layout_standards.count({ where });
|
||||||
|
const layouts = await prisma.layout_standards.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: size,
|
||||||
|
orderBy: { updated_date: "desc" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **getLayoutByCode()** - 레이아웃 단건 조회
|
||||||
|
```typescript
|
||||||
|
// Line 152
|
||||||
|
const layout = await prisma.layout_standards.findFirst({
|
||||||
|
where: { layout_code: code, company_code: companyCode },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **createLayout()** - 레이아웃 생성
|
||||||
|
```typescript
|
||||||
|
// Line 199
|
||||||
|
const layout = await prisma.layout_standards.create({
|
||||||
|
data: {
|
||||||
|
layout_code,
|
||||||
|
layout_name,
|
||||||
|
layout_type,
|
||||||
|
category,
|
||||||
|
layout_config: safeJSONStringify(layout_config),
|
||||||
|
sections: safeJSONStringify(sections),
|
||||||
|
// ... 기타 필드
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **updateLayout()** - 레이아웃 수정
|
||||||
|
```typescript
|
||||||
|
// Line 230, 267
|
||||||
|
const existing = await prisma.layout_standards.findFirst({
|
||||||
|
where: { layout_code: code, company_code: companyCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await prisma.layout_standards.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { ... },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. **deleteLayout()** - 레이아웃 삭제
|
||||||
|
```typescript
|
||||||
|
// Line 283, 295
|
||||||
|
const existing = await prisma.layout_standards.findFirst({
|
||||||
|
where: { layout_code: code, company_code: companyCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.layout_standards.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { is_active: "N", updated_by, updated_date: new Date() },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. **getLayoutStatistics()** - 레이아웃 통계
|
||||||
|
```typescript
|
||||||
|
// Line 345
|
||||||
|
const counts = await prisma.layout_standards.groupBy({
|
||||||
|
by: ["category", "layout_type"],
|
||||||
|
where: { company_code: companyCode, is_active: "Y" },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. **getLayoutCategories()** - 카테고리 목록
|
||||||
|
```typescript
|
||||||
|
// Line 373
|
||||||
|
const existingCodes = await prisma.layout_standards.findMany({
|
||||||
|
where: { company_code: companyCode },
|
||||||
|
select: { category: true },
|
||||||
|
distinct: ["category"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 계획
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
- `getLayouts()` - 목록 조회 (count + findMany)
|
||||||
|
- `getLayoutByCode()` - 단건 조회 (findFirst)
|
||||||
|
- `createLayout()` - 생성 (create)
|
||||||
|
- `updateLayout()` - 수정 (findFirst + update)
|
||||||
|
- `deleteLayout()` - 삭제 (findFirst + update - soft delete)
|
||||||
|
|
||||||
|
### 2단계: 통계 및 집계 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
- `getLayoutStatistics()` - 통계 (groupBy)
|
||||||
|
- `getLayoutCategories()` - 카테고리 목록 (findMany + distinct)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 레이아웃 목록 조회 (동적 WHERE + 페이지네이션)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const where: any = { company_code: companyCode };
|
||||||
|
if (category) where.category = category;
|
||||||
|
if (layoutType) where.layout_type = layoutType;
|
||||||
|
if (searchTerm) {
|
||||||
|
where.OR = [
|
||||||
|
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ layout_code: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = await prisma.layout_standards.count({ where });
|
||||||
|
const layouts = await prisma.layout_standards.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: size,
|
||||||
|
orderBy: { updated_date: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
const whereConditions: string[] = ["company_code = $1"];
|
||||||
|
const values: any[] = [companyCode];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
whereConditions.push(`category = $${paramIndex++}`);
|
||||||
|
values.push(category);
|
||||||
|
}
|
||||||
|
if (layoutType) {
|
||||||
|
whereConditions.push(`layout_type = $${paramIndex++}`);
|
||||||
|
values.push(layoutType);
|
||||||
|
}
|
||||||
|
if (searchTerm) {
|
||||||
|
whereConditions.push(
|
||||||
|
`(layout_name ILIKE $${paramIndex} OR layout_code ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
|
values.push(`%${searchTerm}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
||||||
|
// 총 개수 조회
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const layouts = await query<any>(
|
||||||
|
`SELECT * FROM layout_standards
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY updated_date DESC
|
||||||
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
|
[...values, size, skip]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: JSON 필드 처리 (레이아웃 생성)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const layout = await prisma.layout_standards.create({
|
||||||
|
data: {
|
||||||
|
layout_code,
|
||||||
|
layout_name,
|
||||||
|
layout_config: safeJSONStringify(layout_config), // JSON 필드
|
||||||
|
sections: safeJSONStringify(sections), // JSON 필드
|
||||||
|
company_code: companyCode,
|
||||||
|
created_by: createdBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const layout = await queryOne<any>(
|
||||||
|
`INSERT INTO layout_standards
|
||||||
|
(layout_code, layout_name, layout_type, category, layout_config, sections,
|
||||||
|
company_code, is_active, created_by, updated_by, created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
layout_code,
|
||||||
|
layout_name,
|
||||||
|
layout_type,
|
||||||
|
category,
|
||||||
|
safeJSONStringify(layout_config), // JSON 필드는 문자열로 변환
|
||||||
|
safeJSONStringify(sections),
|
||||||
|
companyCode,
|
||||||
|
"Y",
|
||||||
|
createdBy,
|
||||||
|
updatedBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: GROUP BY 통계 쿼리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const counts = await prisma.layout_standards.groupBy({
|
||||||
|
by: ["category", "layout_type"],
|
||||||
|
where: { company_code: companyCode, is_active: "Y" },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const counts = await query<{
|
||||||
|
category: string;
|
||||||
|
layout_type: string;
|
||||||
|
count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT category, layout_type, COUNT(*) as count
|
||||||
|
FROM layout_standards
|
||||||
|
WHERE company_code = $1 AND is_active = $2
|
||||||
|
GROUP BY category, layout_type`,
|
||||||
|
[companyCode, "Y"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 포맷팅
|
||||||
|
const formattedCounts = counts.map((row) => ({
|
||||||
|
category: row.category,
|
||||||
|
layout_type: row.layout_type,
|
||||||
|
_count: { id: parseInt(row.count) },
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: DISTINCT 쿼리 (카테고리 목록)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const existingCodes = await prisma.layout_standards.findMany({
|
||||||
|
where: { company_code: companyCode },
|
||||||
|
select: { category: true },
|
||||||
|
distinct: ["category"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const existingCodes = await query<{ category: string }>(
|
||||||
|
`SELECT DISTINCT category
|
||||||
|
FROM layout_standards
|
||||||
|
WHERE company_code = $1
|
||||||
|
ORDER BY category`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 완료 기준
|
||||||
|
|
||||||
|
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||||
|
- [ ] **동적 WHERE 조건 생성 (ILIKE, OR)**
|
||||||
|
- [ ] **JSON 필드 처리 (layout_config, sections)**
|
||||||
|
- [ ] **GROUP BY 집계 쿼리 전환**
|
||||||
|
- [ ] **DISTINCT 쿼리 전환**
|
||||||
|
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- [ ] **`import prisma` 완전 제거**
|
||||||
|
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||||
|
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 기술적 과제
|
||||||
|
|
||||||
|
### 1. JSON 필드 처리
|
||||||
|
- `layout_config`, `sections` 필드는 JSON 타입
|
||||||
|
- INSERT/UPDATE 시 `JSON.stringify()` 또는 `safeJSONStringify()` 사용
|
||||||
|
- SELECT 시 PostgreSQL이 자동으로 JSON 객체로 반환
|
||||||
|
|
||||||
|
### 2. 동적 검색 조건
|
||||||
|
- category, layoutType, searchTerm에 따른 동적 WHERE 절
|
||||||
|
- OR 조건 처리 (layout_name OR layout_code)
|
||||||
|
|
||||||
|
### 3. Soft Delete
|
||||||
|
- `deleteLayout()`는 실제 삭제가 아닌 `is_active = 'N'` 업데이트
|
||||||
|
- UPDATE 쿼리 사용
|
||||||
|
|
||||||
|
### 4. 통계 쿼리
|
||||||
|
- `groupBy` → `GROUP BY` + `COUNT(*)` 전환
|
||||||
|
- 결과 포맷팅 필요 (`_count.id` 형태로 변환)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 코드 전환
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] getLayouts() - count + findMany → query + queryOne
|
||||||
|
- [ ] getLayoutByCode() - findFirst → queryOne
|
||||||
|
- [ ] createLayout() - create → queryOne (INSERT)
|
||||||
|
- [ ] updateLayout() - findFirst + update → queryOne (동적 UPDATE)
|
||||||
|
- [ ] deleteLayout() - findFirst + update → queryOne (UPDATE is_active)
|
||||||
|
- [ ] getLayoutStatistics() - groupBy → query (GROUP BY)
|
||||||
|
- [ ] getLayoutCategories() - findMany + distinct → query (DISTINCT)
|
||||||
|
- [ ] JSON 필드 처리 확인 (safeJSONStringify)
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- [ ] 단위 테스트 작성 (10개)
|
||||||
|
- [ ] 통합 테스트 작성 (3개)
|
||||||
|
- [ ] TypeScript 컴파일 성공
|
||||||
|
- [ ] 성능 벤치마크 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### JSON 필드 헬퍼 함수
|
||||||
|
이 서비스는 `safeJSONParse()`, `safeJSONStringify()` 헬퍼 함수를 사용하여 JSON 필드를 안전하게 처리합니다. Raw Query 전환 후에도 이 함수들을 계속 사용해야 합니다.
|
||||||
|
|
||||||
|
### Soft Delete 패턴
|
||||||
|
레이아웃 삭제는 실제 DELETE가 아닌 `is_active = 'N'` 업데이트로 처리되므로, UPDATE 쿼리를 사용해야 합니다.
|
||||||
|
|
||||||
|
### 통계 쿼리 결과 포맷
|
||||||
|
Prisma의 `groupBy`는 `_count: { id: number }` 형태로 반환하지만, Raw Query는 `count: string`으로 반환하므로 포맷팅이 필요합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-10-01
|
||||||
|
**예상 소요 시간**: 1시간
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟡 중간 (Phase 3.7)
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: JSON 필드 처리, GROUP BY, DISTINCT 쿼리 포함
|
||||||
|
|
||||||
|
|
@ -0,0 +1,484 @@
|
||||||
|
# 🗂️ Phase 3.8: DbTypeCategoryService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
DbTypeCategoryService는 **10개의 Prisma 호출**이 있으며, 데이터베이스 타입 카테고리 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------------ |
|
||||||
|
| 파일 위치 | `backend-node/src/services/dbTypeCategoryService.ts` |
|
||||||
|
| 파일 크기 | 320+ 라인 |
|
||||||
|
| Prisma 호출 | 10개 |
|
||||||
|
| **현재 진행률** | **0/10 (0%)** 🔄 **진행 예정** |
|
||||||
|
| 복잡도 | 중간 (CRUD, 통계, UPSERT) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 3.8) |
|
||||||
|
| **상태** | ⏳ **대기 중** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ⏳ **10개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ⏳ ApiResponse 래퍼 패턴 유지
|
||||||
|
- ⏳ GROUP BY 통계 쿼리 전환
|
||||||
|
- ⏳ UPSERT 로직 전환 (ON CONFLICT)
|
||||||
|
- ⏳ 모든 단위 테스트 통과
|
||||||
|
- ⏳ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 Prisma 호출 (10개)
|
||||||
|
|
||||||
|
#### 1. **getAllCategories()** - 카테고리 목록 조회
|
||||||
|
```typescript
|
||||||
|
// Line 45
|
||||||
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
orderBy: [
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
{ display_name: 'asc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **getCategoryByTypeCode()** - 카테고리 단건 조회
|
||||||
|
```typescript
|
||||||
|
// Line 73
|
||||||
|
const category = await prisma.db_type_categories.findUnique({
|
||||||
|
where: { type_code: typeCode }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **createCategory()** - 카테고리 생성
|
||||||
|
```typescript
|
||||||
|
// Line 105, 116
|
||||||
|
const existing = await prisma.db_type_categories.findUnique({
|
||||||
|
where: { type_code: data.type_code }
|
||||||
|
});
|
||||||
|
|
||||||
|
const category = await prisma.db_type_categories.create({
|
||||||
|
data: {
|
||||||
|
type_code: data.type_code,
|
||||||
|
display_name: data.display_name,
|
||||||
|
icon: data.icon,
|
||||||
|
color: data.color,
|
||||||
|
sort_order: data.sort_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **updateCategory()** - 카테고리 수정
|
||||||
|
```typescript
|
||||||
|
// Line 146
|
||||||
|
const category = await prisma.db_type_categories.update({
|
||||||
|
where: { type_code: typeCode },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. **deleteCategory()** - 카테고리 삭제 (연결 확인)
|
||||||
|
```typescript
|
||||||
|
// Line 179, 193
|
||||||
|
const connectionsCount = await prisma.external_db_connections.count({
|
||||||
|
where: { db_type: typeCode }
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.db_type_categories.update({
|
||||||
|
where: { type_code: typeCode },
|
||||||
|
data: { is_active: false }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. **getCategoryStatistics()** - 카테고리별 통계
|
||||||
|
```typescript
|
||||||
|
// Line 220, 229
|
||||||
|
const stats = await prisma.external_db_connections.groupBy({
|
||||||
|
by: ['db_type'],
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
|
where: { is_active: true }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. **syncPredefinedCategories()** - 사전 정의 카테고리 동기화
|
||||||
|
```typescript
|
||||||
|
// Line 300
|
||||||
|
await prisma.db_type_categories.upsert({
|
||||||
|
where: { type_code: category.type_code },
|
||||||
|
update: {
|
||||||
|
display_name: category.display_name,
|
||||||
|
icon: category.icon,
|
||||||
|
color: category.color,
|
||||||
|
sort_order: category.sort_order,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type_code: category.type_code,
|
||||||
|
display_name: category.display_name,
|
||||||
|
icon: category.icon,
|
||||||
|
color: category.color,
|
||||||
|
sort_order: category.sort_order,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 계획
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD 전환 (5개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
- `getAllCategories()` - 목록 조회 (findMany)
|
||||||
|
- `getCategoryByTypeCode()` - 단건 조회 (findUnique)
|
||||||
|
- `createCategory()` - 생성 (findUnique + create)
|
||||||
|
- `updateCategory()` - 수정 (update)
|
||||||
|
- `deleteCategory()` - 삭제 (count + update - soft delete)
|
||||||
|
|
||||||
|
### 2단계: 통계 및 UPSERT 전환 (2개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
- `getCategoryStatistics()` - 통계 (groupBy + findMany)
|
||||||
|
- `syncPredefinedCategories()` - 동기화 (upsert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 카테고리 목록 조회 (정렬)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
|
where: { is_active: true },
|
||||||
|
orderBy: [
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
{ display_name: 'asc' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
const categories = await query<DbTypeCategory>(
|
||||||
|
`SELECT * FROM db_type_categories
|
||||||
|
WHERE is_active = $1
|
||||||
|
ORDER BY sort_order ASC, display_name ASC`,
|
||||||
|
[true]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 카테고리 생성 (중복 확인)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const existing = await prisma.db_type_categories.findUnique({
|
||||||
|
where: { type_code: data.type_code }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 타입 코드입니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.db_type_categories.create({
|
||||||
|
data: {
|
||||||
|
type_code: data.type_code,
|
||||||
|
display_name: data.display_name,
|
||||||
|
icon: data.icon,
|
||||||
|
color: data.color,
|
||||||
|
sort_order: data.sort_order ?? 0,
|
||||||
|
is_active: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
|
const existing = await queryOne<DbTypeCategory>(
|
||||||
|
`SELECT * FROM db_type_categories WHERE type_code = $1`,
|
||||||
|
[data.type_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "이미 존재하는 타입 코드입니다."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await queryOne<DbTypeCategory>(
|
||||||
|
`INSERT INTO db_type_categories
|
||||||
|
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.type_code,
|
||||||
|
data.display_name,
|
||||||
|
data.icon || null,
|
||||||
|
data.color || null,
|
||||||
|
data.sort_order ?? 0,
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 동적 UPDATE (변경된 필드만)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const updateData: any = {};
|
||||||
|
if (data.display_name !== undefined) updateData.display_name = data.display_name;
|
||||||
|
if (data.icon !== undefined) updateData.icon = data.icon;
|
||||||
|
if (data.color !== undefined) updateData.color = data.color;
|
||||||
|
if (data.sort_order !== undefined) updateData.sort_order = data.sort_order;
|
||||||
|
if (data.is_active !== undefined) updateData.is_active = data.is_active;
|
||||||
|
|
||||||
|
const category = await prisma.db_type_categories.update({
|
||||||
|
where: { type_code: typeCode },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const updateFields: string[] = ["updated_at = NOW()"];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.display_name !== undefined) {
|
||||||
|
updateFields.push(`display_name = $${paramIndex++}`);
|
||||||
|
values.push(data.display_name);
|
||||||
|
}
|
||||||
|
if (data.icon !== undefined) {
|
||||||
|
updateFields.push(`icon = $${paramIndex++}`);
|
||||||
|
values.push(data.icon);
|
||||||
|
}
|
||||||
|
if (data.color !== undefined) {
|
||||||
|
updateFields.push(`color = $${paramIndex++}`);
|
||||||
|
values.push(data.color);
|
||||||
|
}
|
||||||
|
if (data.sort_order !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||||
|
values.push(data.sort_order);
|
||||||
|
}
|
||||||
|
if (data.is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(data.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await queryOne<DbTypeCategory>(
|
||||||
|
`UPDATE db_type_categories
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE type_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, typeCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: 삭제 전 연결 확인
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const connectionsCount = await prisma.external_db_connections.count({
|
||||||
|
where: { db_type: typeCode }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (connectionsCount > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.db_type_categories.update({
|
||||||
|
where: { type_code: typeCode },
|
||||||
|
data: { is_active: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM external_db_connections WHERE db_type = $1`,
|
||||||
|
[typeCode]
|
||||||
|
);
|
||||||
|
const connectionsCount = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
|
if (connectionsCount > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `이 카테고리를 사용 중인 연결이 ${connectionsCount}개 있습니다.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE db_type_categories SET is_active = $1, updated_at = NOW() WHERE type_code = $2`,
|
||||||
|
[false, typeCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 5: GROUP BY 통계 + JOIN
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const stats = await prisma.external_db_connections.groupBy({
|
||||||
|
by: ['db_type'],
|
||||||
|
_count: { id: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = await prisma.db_type_categories.findMany({
|
||||||
|
where: { is_active: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const stats = await query<{
|
||||||
|
type_code: string;
|
||||||
|
display_name: string;
|
||||||
|
connection_count: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
c.type_code,
|
||||||
|
c.display_name,
|
||||||
|
COUNT(e.id) as connection_count
|
||||||
|
FROM db_type_categories c
|
||||||
|
LEFT JOIN external_db_connections e ON c.type_code = e.db_type
|
||||||
|
WHERE c.is_active = $1
|
||||||
|
GROUP BY c.type_code, c.display_name
|
||||||
|
ORDER BY c.sort_order ASC`,
|
||||||
|
[true]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 포맷팅
|
||||||
|
const result = stats.map(row => ({
|
||||||
|
type_code: row.type_code,
|
||||||
|
display_name: row.display_name,
|
||||||
|
connection_count: parseInt(row.connection_count),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 6: UPSERT (ON CONFLICT)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
await prisma.db_type_categories.upsert({
|
||||||
|
where: { type_code: category.type_code },
|
||||||
|
update: {
|
||||||
|
display_name: category.display_name,
|
||||||
|
icon: category.icon,
|
||||||
|
color: category.color,
|
||||||
|
sort_order: category.sort_order,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
type_code: category.type_code,
|
||||||
|
display_name: category.display_name,
|
||||||
|
icon: category.icon,
|
||||||
|
color: category.color,
|
||||||
|
sort_order: category.sort_order,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
await query(
|
||||||
|
`INSERT INTO db_type_categories
|
||||||
|
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
|
ON CONFLICT (type_code)
|
||||||
|
DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
icon = EXCLUDED.icon,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
sort_order = EXCLUDED.sort_order,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[
|
||||||
|
category.type_code,
|
||||||
|
category.display_name,
|
||||||
|
category.icon || null,
|
||||||
|
category.color || null,
|
||||||
|
category.sort_order || 0,
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 완료 기준
|
||||||
|
|
||||||
|
- [ ] **10개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||||
|
- [ ] **동적 UPDATE 쿼리 생성**
|
||||||
|
- [ ] **GROUP BY + LEFT JOIN 통계 쿼리**
|
||||||
|
- [ ] **ON CONFLICT를 사용한 UPSERT**
|
||||||
|
- [ ] **ApiResponse 래퍼 패턴 유지**
|
||||||
|
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- [ ] **`import prisma` 완전 제거**
|
||||||
|
- [ ] **모든 단위 테스트 통과 (10개)**
|
||||||
|
- [ ] **통합 테스트 작성 완료 (3개 시나리오)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 기술적 과제
|
||||||
|
|
||||||
|
### 1. ApiResponse 래퍼 패턴
|
||||||
|
모든 함수가 `ApiResponse<T>` 타입을 반환하므로, 에러 처리를 try-catch로 감싸고 일관된 응답 형식을 유지해야 합니다.
|
||||||
|
|
||||||
|
### 2. Soft Delete 패턴
|
||||||
|
`deleteCategory()`는 실제 DELETE가 아닌 `is_active = false` 업데이트로 처리됩니다.
|
||||||
|
|
||||||
|
### 3. 연결 확인
|
||||||
|
카테고리 삭제 전 `external_db_connections` 테이블에서 사용 중인지 확인해야 합니다.
|
||||||
|
|
||||||
|
### 4. UPSERT 로직
|
||||||
|
PostgreSQL의 `ON CONFLICT` 절을 사용하여 Prisma의 `upsert` 기능을 구현합니다.
|
||||||
|
|
||||||
|
### 5. 통계 쿼리 최적화
|
||||||
|
`groupBy` + 별도 조회 대신, 하나의 `LEFT JOIN` + `GROUP BY` 쿼리로 최적화 가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 코드 전환
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] getAllCategories() - findMany → query
|
||||||
|
- [ ] getCategoryByTypeCode() - findUnique → queryOne
|
||||||
|
- [ ] createCategory() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||||
|
- [ ] updateCategory() - update → queryOne (동적 UPDATE)
|
||||||
|
- [ ] deleteCategory() - count + update → queryOne + query
|
||||||
|
- [ ] getCategoryStatistics() - groupBy + findMany → query (LEFT JOIN)
|
||||||
|
- [ ] syncPredefinedCategories() - upsert → query (ON CONFLICT)
|
||||||
|
- [ ] ApiResponse 래퍼 유지
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
- [ ] 단위 테스트 작성 (10개)
|
||||||
|
- [ ] 통합 테스트 작성 (3개)
|
||||||
|
- [ ] TypeScript 컴파일 성공
|
||||||
|
- [ ] 성능 벤치마크 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### ApiResponse 패턴
|
||||||
|
이 서비스는 모든 메서드가 `ApiResponse<T>` 형식으로 응답을 반환합니다. Raw Query 전환 후에도 이 패턴을 유지해야 합니다.
|
||||||
|
|
||||||
|
### 사전 정의 카테고리
|
||||||
|
`syncPredefinedCategories()` 메서드는 시스템 초기화 시 사전 정의된 DB 타입 카테고리를 동기화합니다. UPSERT 로직이 필수입니다.
|
||||||
|
|
||||||
|
### 외래 키 확인
|
||||||
|
카테고리 삭제 시 `external_db_connections` 테이블에서 사용 중인지 확인하여 데이터 무결성을 보장합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-10-01
|
||||||
|
**예상 소요 시간**: 1시간
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟡 중간 (Phase 3.8)
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: ApiResponse 래퍼, UPSERT, GROUP BY + LEFT JOIN 포함
|
||||||
|
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
# 📋 Phase 3.9: TemplateStandardService Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
TemplateStandardService는 **6개의 Prisma 호출**이 있으며, 템플릿 표준 관리를 담당하는 서비스입니다.
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------------ |
|
||||||
|
| 파일 위치 | `backend-node/src/services/templateStandardService.ts` |
|
||||||
|
| 파일 크기 | 395 라인 |
|
||||||
|
| Prisma 호출 | 6개 |
|
||||||
|
| **현재 진행률** | **7/7 (100%)** ✅ **전환 완료** |
|
||||||
|
| 복잡도 | 낮음 (기본 CRUD + DISTINCT) |
|
||||||
|
| 우선순위 | 🟢 낮음 (Phase 3.9) |
|
||||||
|
| **상태** | ✅ **완료** |
|
||||||
|
|
||||||
|
### 🎯 전환 목표
|
||||||
|
|
||||||
|
- ✅ **7개 모든 Prisma 호출을 `db.ts`의 `query()`, `queryOne()` 함수로 교체**
|
||||||
|
- ✅ 템플릿 CRUD 기능 정상 동작
|
||||||
|
- ✅ DISTINCT 쿼리 전환
|
||||||
|
- ✅ Promise.all 병렬 쿼리 (목록 + 개수)
|
||||||
|
- ✅ 동적 UPDATE 쿼리 (11개 필드)
|
||||||
|
- ✅ TypeScript 컴파일 성공
|
||||||
|
- ✅ **Prisma import 완전 제거**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 사용 현황 분석
|
||||||
|
|
||||||
|
### 주요 Prisma 호출 (6개)
|
||||||
|
|
||||||
|
#### 1. **getTemplateByCode()** - 템플릿 단건 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 76
|
||||||
|
return await prisma.template_standards.findUnique({
|
||||||
|
where: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **createTemplate()** - 템플릿 생성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 86
|
||||||
|
const existing = await prisma.template_standards.findUnique({
|
||||||
|
where: {
|
||||||
|
template_code: data.template_code,
|
||||||
|
company_code: data.company_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Line 96
|
||||||
|
return await prisma.template_standards.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_date: new Date(),
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **updateTemplate()** - 템플릿 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 164
|
||||||
|
return await prisma.template_standards.update({
|
||||||
|
where: {
|
||||||
|
template_code_company_code: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. **deleteTemplate()** - 템플릿 삭제
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 181
|
||||||
|
await prisma.template_standards.delete({
|
||||||
|
where: {
|
||||||
|
template_code_company_code: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. **getTemplateCategories()** - 카테고리 목록 (DISTINCT)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 262
|
||||||
|
const categories = await prisma.template_standards.findMany({
|
||||||
|
where: {
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
distinct: ["category"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 계획
|
||||||
|
|
||||||
|
### 1단계: 기본 CRUD 전환 (4개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `getTemplateByCode()` - 단건 조회 (findUnique)
|
||||||
|
- `createTemplate()` - 생성 (findUnique + create)
|
||||||
|
- `updateTemplate()` - 수정 (update)
|
||||||
|
- `deleteTemplate()` - 삭제 (delete)
|
||||||
|
|
||||||
|
### 2단계: 추가 기능 전환 (1개 함수)
|
||||||
|
|
||||||
|
**함수 목록**:
|
||||||
|
|
||||||
|
- `getTemplateCategories()` - 카테고리 목록 (findMany + distinct)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 전환 예시
|
||||||
|
|
||||||
|
### 예시 1: 복합 키 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
return await prisma.template_standards.findUnique({
|
||||||
|
where: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { queryOne } from "../database/db";
|
||||||
|
|
||||||
|
return await queryOne<any>(
|
||||||
|
`SELECT * FROM template_standards
|
||||||
|
WHERE template_code = $1 AND company_code = $2`,
|
||||||
|
[templateCode, companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 2: 중복 확인 후 생성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const existing = await prisma.template_standards.findUnique({
|
||||||
|
where: {
|
||||||
|
template_code: data.template_code,
|
||||||
|
company_code: data.company_code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.template_standards.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
created_date: new Date(),
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const existing = await queryOne<any>(
|
||||||
|
`SELECT * FROM template_standards
|
||||||
|
WHERE template_code = $1 AND company_code = $2`,
|
||||||
|
[data.template_code, data.company_code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("이미 존재하는 템플릿 코드입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await queryOne<any>(
|
||||||
|
`INSERT INTO template_standards
|
||||||
|
(template_code, template_name, category, template_type, layout_config,
|
||||||
|
description, is_active, company_code, created_by, updated_by,
|
||||||
|
created_date, updated_date)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
data.template_code,
|
||||||
|
data.template_name,
|
||||||
|
data.category,
|
||||||
|
data.template_type,
|
||||||
|
JSON.stringify(data.layout_config),
|
||||||
|
data.description,
|
||||||
|
data.is_active,
|
||||||
|
data.company_code,
|
||||||
|
data.created_by,
|
||||||
|
data.updated_by,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 3: 복합 키 UPDATE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
return await prisma.template_standards.update({
|
||||||
|
where: {
|
||||||
|
template_code_company_code: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
// 동적 UPDATE 쿼리 생성
|
||||||
|
const updateFields: string[] = ["updated_date = NOW()"];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.template_name !== undefined) {
|
||||||
|
updateFields.push(`template_name = $${paramIndex++}`);
|
||||||
|
values.push(data.template_name);
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
updateFields.push(`category = $${paramIndex++}`);
|
||||||
|
values.push(data.category);
|
||||||
|
}
|
||||||
|
if (data.template_type !== undefined) {
|
||||||
|
updateFields.push(`template_type = $${paramIndex++}`);
|
||||||
|
values.push(data.template_type);
|
||||||
|
}
|
||||||
|
if (data.layout_config !== undefined) {
|
||||||
|
updateFields.push(`layout_config = $${paramIndex++}`);
|
||||||
|
values.push(JSON.stringify(data.layout_config));
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(data.is_active);
|
||||||
|
}
|
||||||
|
if (data.updated_by !== undefined) {
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(data.updated_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await queryOne<any>(
|
||||||
|
`UPDATE template_standards
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE template_code = $${paramIndex++} AND company_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, templateCode, companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 4: 복합 키 DELETE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
await prisma.template_standards.delete({
|
||||||
|
where: {
|
||||||
|
template_code_company_code: {
|
||||||
|
template_code: templateCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM template_standards
|
||||||
|
WHERE template_code = $1 AND company_code = $2`,
|
||||||
|
[templateCode, companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예시 5: DISTINCT 쿼리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 Prisma
|
||||||
|
const categories = await prisma.template_standards.findMany({
|
||||||
|
where: {
|
||||||
|
company_code: companyCode,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
category: true,
|
||||||
|
},
|
||||||
|
distinct: ["category"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return categories
|
||||||
|
.map((c) => c.category)
|
||||||
|
.filter((c): c is string => c !== null && c !== undefined)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
// 전환 후
|
||||||
|
const categories = await query<{ category: string }>(
|
||||||
|
`SELECT DISTINCT category
|
||||||
|
FROM template_standards
|
||||||
|
WHERE company_code = $1 AND category IS NOT NULL
|
||||||
|
ORDER BY category ASC`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return categories.map((c) => c.category);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 완료 기준
|
||||||
|
|
||||||
|
- [ ] **6개 모든 Prisma 호출을 Raw Query로 전환 완료**
|
||||||
|
- [ ] **복합 기본 키 처리 (template_code + company_code)**
|
||||||
|
- [ ] **동적 UPDATE 쿼리 생성**
|
||||||
|
- [ ] **DISTINCT 쿼리 전환**
|
||||||
|
- [ ] **JSON 필드 처리 (layout_config)**
|
||||||
|
- [ ] **모든 TypeScript 컴파일 오류 해결**
|
||||||
|
- [ ] **`import prisma` 완전 제거**
|
||||||
|
- [ ] **모든 단위 테스트 통과 (6개)**
|
||||||
|
- [ ] **통합 테스트 작성 완료 (2개 시나리오)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 주요 기술적 과제
|
||||||
|
|
||||||
|
### 1. 복합 기본 키
|
||||||
|
|
||||||
|
`template_standards` 테이블은 `(template_code, company_code)` 복합 기본 키를 사용합니다.
|
||||||
|
|
||||||
|
- WHERE 절에서 두 컬럼 모두 지정 필요
|
||||||
|
- Prisma의 `template_code_company_code` 표현식을 `template_code = $1 AND company_code = $2`로 변환
|
||||||
|
|
||||||
|
### 2. JSON 필드
|
||||||
|
|
||||||
|
`layout_config` 필드는 JSON 타입으로, INSERT/UPDATE 시 `JSON.stringify()` 필요합니다.
|
||||||
|
|
||||||
|
### 3. DISTINCT + NULL 제외
|
||||||
|
|
||||||
|
카테고리 목록 조회 시 `DISTINCT` 사용하며, NULL 값은 `WHERE category IS NOT NULL`로 제외합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### 코드 전환
|
||||||
|
|
||||||
|
- [ ] import 문 수정 (`prisma` → `query, queryOne`)
|
||||||
|
- [ ] getTemplateByCode() - findUnique → queryOne (복합 키)
|
||||||
|
- [ ] createTemplate() - findUnique + create → queryOne (중복 확인 + INSERT)
|
||||||
|
- [ ] updateTemplate() - update → queryOne (동적 UPDATE, 복합 키)
|
||||||
|
- [ ] deleteTemplate() - delete → query (복합 키)
|
||||||
|
- [ ] getTemplateCategories() - findMany + distinct → query (DISTINCT)
|
||||||
|
- [ ] JSON 필드 처리 (layout_config)
|
||||||
|
- [ ] Prisma import 완전 제거
|
||||||
|
|
||||||
|
### 테스트
|
||||||
|
|
||||||
|
- [ ] 단위 테스트 작성 (6개)
|
||||||
|
- [ ] 통합 테스트 작성 (2개)
|
||||||
|
- [ ] TypeScript 컴파일 성공
|
||||||
|
- [ ] 성능 벤치마크 테스트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 특이사항
|
||||||
|
|
||||||
|
### 복합 기본 키 패턴
|
||||||
|
|
||||||
|
이 서비스는 `(template_code, company_code)` 복합 기본 키를 사용하므로, 모든 조회/수정/삭제 작업에서 두 컬럼을 모두 WHERE 조건에 포함해야 합니다.
|
||||||
|
|
||||||
|
### JSON 레이아웃 설정
|
||||||
|
|
||||||
|
`layout_config` 필드는 템플릿의 레이아웃 설정을 JSON 형태로 저장합니다. Raw Query 전환 시 `JSON.stringify()`를 사용하여 문자열로 변환해야 합니다.
|
||||||
|
|
||||||
|
### 카테고리 관리
|
||||||
|
|
||||||
|
템플릿은 카테고리별로 분류되며, `getTemplateCategories()` 메서드로 고유한 카테고리 목록을 조회할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-10-01
|
||||||
|
**예상 소요 시간**: 45분
|
||||||
|
**담당자**: 백엔드 개발팀
|
||||||
|
**우선순위**: 🟢 낮음 (Phase 3.9)
|
||||||
|
**상태**: ⏳ **대기 중**
|
||||||
|
**특이사항**: 복합 기본 키, JSON 필드, DISTINCT 쿼리 포함
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
# Phase 4.1: AdminController Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
관리자 컨트롤러의 Prisma 호출을 Raw Query로 전환합니다.
|
||||||
|
사용자, 회사, 부서, 메뉴 관리 등 핵심 관리 기능을 포함합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ------------------------------------------------- |
|
||||||
|
| 파일 위치 | `backend-node/src/controllers/adminController.ts` |
|
||||||
|
| 파일 크기 | 2,569 라인 |
|
||||||
|
| Prisma 호출 | 28개 → 0개 |
|
||||||
|
| **현재 진행률** | **28/28 (100%)** ✅ **완료** |
|
||||||
|
| 복잡도 | 중간 (다양한 CRUD 패턴) |
|
||||||
|
| 우선순위 | 🔴 높음 (Phase 4.1) |
|
||||||
|
| **상태** | ✅ **완료** (2025-10-01) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Prisma 호출 분석
|
||||||
|
|
||||||
|
### 사용자 관리 (13개)
|
||||||
|
|
||||||
|
#### 1. getUserList (라인 312-317)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const totalCount = await prisma.user_info.count({ where });
|
||||||
|
const users = await prisma.user_info.findMany({ where, skip, take, orderBy });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: count → `queryOne`, findMany → `query`
|
||||||
|
- **복잡도**: 중간 (동적 WHERE, 페이징)
|
||||||
|
|
||||||
|
#### 2. getUserInfo (라인 419)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const userInfo = await prisma.user_info.findFirst({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findFirst → `queryOne`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 3. updateUserStatus (라인 498)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.user_info.update({ where, data });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: update → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 4. deleteUserByAdmin (라인 2387)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.user_info.update({ where, data: { is_active: "N" } });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: update (soft delete) → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 5. getMyProfile (라인 1468, 1488, 2479)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const user = await prisma.user_info.findUnique({ where });
|
||||||
|
const dept = await prisma.dept_info.findUnique({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findUnique → `queryOne`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 6. updateMyProfile (라인 1864, 2527)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updateResult = await prisma.user_info.update({ where, data });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: update → `queryOne` with RETURNING
|
||||||
|
- **복잡도**: 중간 (동적 UPDATE)
|
||||||
|
|
||||||
|
#### 7. createOrUpdateUser (라인 1929, 1975)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const savedUser = await prisma.user_info.upsert({ where, update, create });
|
||||||
|
const userCount = await prisma.user_info.count({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: upsert → `INSERT ... ON CONFLICT`, count → `queryOne`
|
||||||
|
- **복잡도**: 높음
|
||||||
|
|
||||||
|
#### 8. 기타 findUnique (라인 1596, 1832, 2393)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingUser = await prisma.user_info.findUnique({ where });
|
||||||
|
const currentUser = await prisma.user_info.findUnique({ where });
|
||||||
|
const updatedUser = await prisma.user_info.findUnique({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findUnique → `queryOne`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
### 회사 관리 (7개)
|
||||||
|
|
||||||
|
#### 9. getCompanyList (라인 550, 1276)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const companies = await prisma.company_mng.findMany({ orderBy });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findMany → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 10. createCompany (라인 2035)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingCompany = await prisma.company_mng.findFirst({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findFirst (중복 체크) → `queryOne`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 11. updateCompany (라인 2172, 2192)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const duplicateCompany = await prisma.company_mng.findFirst({ where });
|
||||||
|
const updatedCompany = await prisma.company_mng.update({ where, data });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findFirst → `queryOne`, update → `queryOne`
|
||||||
|
- **복잡도**: 중간
|
||||||
|
|
||||||
|
#### 12. deleteCompany (라인 2261, 2281)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingCompany = await prisma.company_mng.findUnique({ where });
|
||||||
|
await prisma.company_mng.delete({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findUnique → `queryOne`, delete → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
### 부서 관리 (2개)
|
||||||
|
|
||||||
|
#### 13. getDepartmentList (라인 1348)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const departments = await prisma.dept_info.findMany({ where, orderBy });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findMany → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
#### 14. getDeptInfo (라인 1488)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const dept = await prisma.dept_info.findUnique({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findUnique → `queryOne`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
### 메뉴 관리 (3개)
|
||||||
|
|
||||||
|
#### 15. createMenu (라인 1021)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const savedMenu = await prisma.menu_info.create({ data });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: create → `queryOne` with INSERT RETURNING
|
||||||
|
- **복잡도**: 중간
|
||||||
|
|
||||||
|
#### 16. updateMenu (라인 1087)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updatedMenu = await prisma.menu_info.update({ where, data });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: update → `queryOne` with UPDATE RETURNING
|
||||||
|
- **복잡도**: 중간
|
||||||
|
|
||||||
|
#### 17. deleteMenu (라인 1149, 1211)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||||
|
// 재귀 삭제
|
||||||
|
const deletedMenu = await prisma.menu_info.delete({ where });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: delete → `query`
|
||||||
|
- **복잡도**: 중간 (재귀 삭제 로직)
|
||||||
|
|
||||||
|
### 다국어 (1개)
|
||||||
|
|
||||||
|
#### 18. getMultiLangKeys (라인 665)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await prisma.multi_lang_key_master.findMany({ where, orderBy });
|
||||||
|
```
|
||||||
|
|
||||||
|
- **전환**: findMany → `query`
|
||||||
|
- **복잡도**: 낮음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 전략
|
||||||
|
|
||||||
|
### 1단계: Import 변경
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 제거
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 추가
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: 단순 조회 전환
|
||||||
|
|
||||||
|
- findMany → `query<T>`
|
||||||
|
- findUnique/findFirst → `queryOne<T>`
|
||||||
|
|
||||||
|
### 3단계: 동적 WHERE 처리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (companyCode) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4단계: 복잡한 로직 전환
|
||||||
|
|
||||||
|
- count → `SELECT COUNT(*) as count`
|
||||||
|
- upsert → `INSERT ... ON CONFLICT DO UPDATE`
|
||||||
|
- 동적 UPDATE → 조건부 SET 절 생성
|
||||||
|
|
||||||
|
### 5단계: 테스트 및 검증
|
||||||
|
|
||||||
|
- 각 함수별 동작 확인
|
||||||
|
- 에러 처리 확인
|
||||||
|
- 타입 안전성 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 주요 변경 예시
|
||||||
|
|
||||||
|
### getUserList (count + findMany)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const totalCount = await prisma.user_info.count({ where });
|
||||||
|
const users = await prisma.user_info.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
orderBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 동적 WHERE 구성
|
||||||
|
if (where.company_code) {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(where.company_code);
|
||||||
|
}
|
||||||
|
if (where.user_name) {
|
||||||
|
whereConditions.push(`user_name ILIKE $${paramIndex++}`);
|
||||||
|
params.push(`%${where.user_name}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
// Count
|
||||||
|
const countResult = await queryOne<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM user_info ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const totalCount = parseInt(countResult?.count?.toString() || "0", 10);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const usersQuery = `
|
||||||
|
SELECT * FROM user_info
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_date DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
params.push(take, skip);
|
||||||
|
|
||||||
|
const users = await query<UserInfo>(usersQuery, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
### createOrUpdateUser (upsert)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const savedUser = await prisma.user_info.upsert({
|
||||||
|
where: { user_id: userId },
|
||||||
|
update: updateData,
|
||||||
|
create: createData
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const savedUser = await queryOne<UserInfo>(
|
||||||
|
`INSERT INTO user_info (user_id, user_name, email, ...)
|
||||||
|
VALUES ($1, $2, $3, ...)
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
user_name = EXCLUDED.user_name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
...
|
||||||
|
RETURNING *`,
|
||||||
|
[userId, userName, email, ...]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### updateMyProfile (동적 UPDATE)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const updateResult = await prisma.user_info.update({
|
||||||
|
where: { user_id: userId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const updates: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (updateData.user_name !== undefined) {
|
||||||
|
updates.push(`user_name = $${paramIndex++}`);
|
||||||
|
params.push(updateData.user_name);
|
||||||
|
}
|
||||||
|
if (updateData.email !== undefined) {
|
||||||
|
updates.push(`email = $${paramIndex++}`);
|
||||||
|
params.push(updateData.email);
|
||||||
|
}
|
||||||
|
// ... 다른 필드들
|
||||||
|
|
||||||
|
params.push(userId);
|
||||||
|
|
||||||
|
const updateResult = await queryOne<UserInfo>(
|
||||||
|
`UPDATE user_info
|
||||||
|
SET ${updates.join(", ")}, updated_date = NOW()
|
||||||
|
WHERE user_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
### 기본 설정
|
||||||
|
|
||||||
|
- ✅ Prisma import 제거 (완전 제거 확인)
|
||||||
|
- ✅ query, queryOne import 추가 (이미 존재)
|
||||||
|
- ✅ 타입 import 확인
|
||||||
|
|
||||||
|
### 사용자 관리
|
||||||
|
|
||||||
|
- ✅ getUserList (count + findMany → Raw Query)
|
||||||
|
- ✅ getUserLocale (findFirst → queryOne)
|
||||||
|
- ✅ setUserLocale (update → query)
|
||||||
|
- ✅ getUserInfo (findUnique → queryOne)
|
||||||
|
- ✅ checkDuplicateUserId (findUnique → queryOne)
|
||||||
|
- ✅ changeUserStatus (findUnique + update → queryOne + query)
|
||||||
|
- ✅ saveUser (upsert → INSERT ON CONFLICT)
|
||||||
|
- ✅ updateProfile (동적 update → 동적 query)
|
||||||
|
- ✅ resetUserPassword (update → query)
|
||||||
|
|
||||||
|
### 회사 관리
|
||||||
|
|
||||||
|
- ✅ getCompanyList (findMany → query)
|
||||||
|
- ✅ getCompanyListFromDB (findMany → query)
|
||||||
|
- ✅ createCompany (findFirst → queryOne)
|
||||||
|
- ✅ updateCompany (findFirst + update → queryOne + query)
|
||||||
|
- ✅ deleteCompany (delete → query with RETURNING)
|
||||||
|
|
||||||
|
### 부서 관리
|
||||||
|
|
||||||
|
- ✅ getDepartmentList (findMany → query with 동적 WHERE)
|
||||||
|
|
||||||
|
### 메뉴 관리
|
||||||
|
|
||||||
|
- ✅ saveMenu (create → query with INSERT RETURNING)
|
||||||
|
- ✅ updateMenu (update → query with UPDATE RETURNING)
|
||||||
|
- ✅ deleteMenu (delete → query with DELETE RETURNING)
|
||||||
|
- ✅ deleteMenusBatch (다중 delete → 반복 query)
|
||||||
|
|
||||||
|
### 다국어
|
||||||
|
|
||||||
|
- ✅ getLangKeyList (findMany → query)
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
|
||||||
|
- ✅ TypeScript 컴파일 확인 (에러 없음)
|
||||||
|
- ✅ Linter 오류 확인
|
||||||
|
- ⏳ 기능 테스트 (실행 필요)
|
||||||
|
- ✅ 에러 처리 확인 (기존 구조 유지)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 참고사항
|
||||||
|
|
||||||
|
### 동적 쿼리 생성 패턴
|
||||||
|
|
||||||
|
모든 동적 WHERE/UPDATE는 다음 패턴을 따릅니다:
|
||||||
|
|
||||||
|
1. 조건/필드 배열 생성
|
||||||
|
2. 파라미터 배열 생성
|
||||||
|
3. 파라미터 인덱스 관리
|
||||||
|
4. SQL 문자열 조합
|
||||||
|
5. query/queryOne 실행
|
||||||
|
|
||||||
|
### 에러 처리
|
||||||
|
|
||||||
|
기존 try-catch 구조를 유지하며, 데이터베이스 에러를 적절히 변환합니다.
|
||||||
|
|
||||||
|
### 트랜잭션
|
||||||
|
|
||||||
|
복잡한 로직은 Service Layer로 이동을 고려합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 완료 요약 (2025-10-01)
|
||||||
|
|
||||||
|
### ✅ 전환 완료 현황
|
||||||
|
|
||||||
|
| 카테고리 | 함수 수 | 상태 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| 사용자 관리 | 9개 | ✅ 완료 |
|
||||||
|
| 회사 관리 | 5개 | ✅ 완료 |
|
||||||
|
| 부서 관리 | 1개 | ✅ 완료 |
|
||||||
|
| 메뉴 관리 | 4개 | ✅ 완료 |
|
||||||
|
| 다국어 | 1개 | ✅ 완료 |
|
||||||
|
| **총계** | **20개** | **✅ 100% 완료** |
|
||||||
|
|
||||||
|
### 📊 주요 성과
|
||||||
|
|
||||||
|
1. **완전한 Prisma 제거**: adminController.ts에서 모든 Prisma 코드 제거 완료
|
||||||
|
2. **동적 쿼리 지원**: 런타임 테이블 생성/수정 가능
|
||||||
|
3. **일관된 에러 처리**: 모든 함수에서 통일된 에러 처리 유지
|
||||||
|
4. **타입 안전성**: TypeScript 컴파일 에러 없음
|
||||||
|
5. **코드 품질 향상**: 949줄 변경 (+474/-475)
|
||||||
|
|
||||||
|
### 🔑 주요 변환 패턴
|
||||||
|
|
||||||
|
#### 1. 동적 WHERE 조건
|
||||||
|
```typescript
|
||||||
|
let whereConditions: string[] = [];
|
||||||
|
let queryParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filter) {
|
||||||
|
whereConditions.push(`field = $${paramIndex}`);
|
||||||
|
queryParams.push(filter);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. UPSERT (INSERT ON CONFLICT)
|
||||||
|
```typescript
|
||||||
|
const [result] = await query<any>(
|
||||||
|
`INSERT INTO table (col1, col2) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (col1) DO UPDATE SET col2 = $2
|
||||||
|
RETURNING *`,
|
||||||
|
[val1, val2]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 동적 UPDATE
|
||||||
|
```typescript
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const updateValues: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.field !== undefined) {
|
||||||
|
updateFields.push(`field = $${paramIndex}`);
|
||||||
|
updateValues.push(data.field);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE table SET ${updateFields.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
[...updateValues, id]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🚀 다음 단계
|
||||||
|
|
||||||
|
1. **테스트 실행**: 개발 서버에서 모든 API 엔드포인트 테스트
|
||||||
|
2. **문서 업데이트**: Phase 4 전체 계획서 진행 상황 반영
|
||||||
|
3. **다음 Phase**: screenFileController.ts 마이그레이션 진행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**마지막 업데이트**: 2025-10-01
|
||||||
|
**작업자**: Claude Agent
|
||||||
|
**완료 시간**: 약 15분
|
||||||
|
**변경 라인 수**: 949줄 (추가 474줄, 삭제 475줄)
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
# Phase 4: Controller Layer Raw Query 전환 계획
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
컨트롤러 레이어에 남아있는 Prisma 호출을 Raw Query로 전환합니다.
|
||||||
|
대부분의 컨트롤러는 Service 레이어를 호출하지만, 일부 컨트롤러에서 직접 Prisma를 사용하고 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 기본 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | ---------------------------------- |
|
||||||
|
| 대상 파일 | 7개 컨트롤러 |
|
||||||
|
| 파일 위치 | `backend-node/src/controllers/` |
|
||||||
|
| Prisma 호출 | 70개 (28개 완료) |
|
||||||
|
| **현재 진행률** | **28/70 (40%)** 🔄 **진행 중** |
|
||||||
|
| 복잡도 | 중간 (대부분 단순 CRUD) |
|
||||||
|
| 우선순위 | 🟡 중간 (Phase 4) |
|
||||||
|
| **상태** | 🔄 **진행 중** (adminController 완료) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 전환 대상 컨트롤러
|
||||||
|
|
||||||
|
### 1. adminController.ts ✅ 완료 (28개)
|
||||||
|
|
||||||
|
- **라인 수**: 2,569 라인
|
||||||
|
- **Prisma 호출**: 28개 → 0개
|
||||||
|
- **주요 기능**:
|
||||||
|
- 사용자 관리 (조회, 생성, 수정, 삭제) ✅
|
||||||
|
- 회사 관리 (조회, 생성, 수정, 삭제) ✅
|
||||||
|
- 부서 관리 (조회) ✅
|
||||||
|
- 메뉴 관리 (생성, 수정, 삭제) ✅
|
||||||
|
- 다국어 키 조회 ✅
|
||||||
|
- **우선순위**: 🔴 높음
|
||||||
|
- **상태**: ✅ **완료** (2025-10-01)
|
||||||
|
- **문서**: [PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md](PHASE4.1_ADMIN_CONTROLLER_MIGRATION.md)
|
||||||
|
|
||||||
|
### 2. webTypeStandardController.ts (11개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 11개
|
||||||
|
- **주요 기능**: 웹타입 표준 관리
|
||||||
|
- **우선순위**: 🟡 중간
|
||||||
|
|
||||||
|
### 3. fileController.ts (11개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 11개
|
||||||
|
- **주요 기능**: 파일 업로드/다운로드 관리
|
||||||
|
- **우선순위**: 🟡 중간
|
||||||
|
|
||||||
|
### 4. buttonActionStandardController.ts (11개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 11개
|
||||||
|
- **주요 기능**: 버튼 액션 표준 관리
|
||||||
|
- **우선순위**: 🟡 중간
|
||||||
|
|
||||||
|
### 5. entityReferenceController.ts (4개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 4개
|
||||||
|
- **주요 기능**: 엔티티 참조 관리
|
||||||
|
- **우선순위**: 🟢 낮음
|
||||||
|
|
||||||
|
### 6. dataflowExecutionController.ts (3개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 3개
|
||||||
|
- **주요 기능**: 데이터플로우 실행
|
||||||
|
- **우선순위**: 🟢 낮음
|
||||||
|
|
||||||
|
### 7. screenFileController.ts (2개)
|
||||||
|
|
||||||
|
- **Prisma 호출**: 2개
|
||||||
|
- **주요 기능**: 화면 파일 관리
|
||||||
|
- **우선순위**: 🟢 낮음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 전환 전략
|
||||||
|
|
||||||
|
### 기본 원칙
|
||||||
|
|
||||||
|
1. **Service Layer 우선**
|
||||||
|
|
||||||
|
- 가능하면 Service로 로직 이동
|
||||||
|
- Controller는 최소한의 로직만 유지
|
||||||
|
|
||||||
|
2. **단순 전환**
|
||||||
|
|
||||||
|
- 대부분 단순 CRUD → `query`, `queryOne` 사용
|
||||||
|
- 복잡한 로직은 Service로 이동
|
||||||
|
|
||||||
|
3. **에러 처리 유지**
|
||||||
|
- 기존 try-catch 구조 유지
|
||||||
|
- 에러 메시지 일관성 유지
|
||||||
|
|
||||||
|
### 전환 패턴
|
||||||
|
|
||||||
|
#### 1. findMany → query
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const users = await prisma.user_info.findMany({
|
||||||
|
where: { company_code: companyCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const users = await query<UserInfo>(
|
||||||
|
`SELECT * FROM user_info WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. findUnique → queryOne
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const user = await prisma.user_info.findUnique({
|
||||||
|
where: { user_id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const user = await queryOne<UserInfo>(
|
||||||
|
`SELECT * FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. create → queryOne with INSERT
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const newUser = await prisma.user_info.create({
|
||||||
|
data: userData
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const newUser = await queryOne<UserInfo>(
|
||||||
|
`INSERT INTO user_info (user_id, user_name, ...)
|
||||||
|
VALUES ($1, $2, ...) RETURNING *`,
|
||||||
|
[userData.user_id, userData.user_name, ...]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. update → queryOne with UPDATE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const updated = await prisma.user_info.update({
|
||||||
|
where: { user_id: userId },
|
||||||
|
data: updateData
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const updated = await queryOne<UserInfo>(
|
||||||
|
`UPDATE user_info SET user_name = $1, ...
|
||||||
|
WHERE user_id = $2 RETURNING *`,
|
||||||
|
[updateData.user_name, ..., userId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. delete → query with DELETE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
await prisma.user_info.delete({
|
||||||
|
where: { user_id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
await query(`DELETE FROM user_info WHERE user_id = $1`, [userId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. count → queryOne
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const count = await prisma.user_info.count({
|
||||||
|
where: { company_code: companyCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const result = await queryOne<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM user_info WHERE company_code = $1`,
|
||||||
|
[companyCode]
|
||||||
|
);
|
||||||
|
const count = parseInt(result?.count?.toString() || "0", 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
### Phase 4.1: adminController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 사용자 관리 함수 전환 (8개)
|
||||||
|
- [ ] getUserList - count + findMany
|
||||||
|
- [ ] getUserInfo - findFirst
|
||||||
|
- [ ] updateUserStatus - update
|
||||||
|
- [ ] deleteUserByAdmin - update
|
||||||
|
- [ ] getMyProfile - findUnique
|
||||||
|
- [ ] updateMyProfile - update
|
||||||
|
- [ ] createOrUpdateUser - upsert
|
||||||
|
- [ ] count (getUserList)
|
||||||
|
- [ ] 회사 관리 함수 전환 (7개)
|
||||||
|
- [ ] getCompanyList - findMany
|
||||||
|
- [ ] createCompany - findFirst (중복체크) + create
|
||||||
|
- [ ] updateCompany - findFirst (중복체크) + update
|
||||||
|
- [ ] deleteCompany - findUnique + delete
|
||||||
|
- [ ] 부서 관리 함수 전환 (2개)
|
||||||
|
- [ ] getDepartmentList - findMany
|
||||||
|
- [ ] findUnique (부서 조회)
|
||||||
|
- [ ] 메뉴 관리 함수 전환 (3개)
|
||||||
|
- [ ] createMenu - create
|
||||||
|
- [ ] updateMenu - update
|
||||||
|
- [ ] deleteMenu - delete
|
||||||
|
- [ ] 기타 함수 전환 (8개)
|
||||||
|
- [ ] getMultiLangKeys - findMany
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.2: webTypeStandardController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (11개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.3: fileController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (11개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.4: buttonActionStandardController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (11개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.5: entityReferenceController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (4개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.6: dataflowExecutionController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (3개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
### Phase 4.7: screenFileController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] 모든 함수 전환 (2개)
|
||||||
|
- [ ] 컴파일 확인
|
||||||
|
- [ ] 린터 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 예상 결과
|
||||||
|
|
||||||
|
### 코드 품질
|
||||||
|
|
||||||
|
- ✅ Prisma 의존성 완전 제거
|
||||||
|
- ✅ 직접적인 SQL 제어
|
||||||
|
- ✅ 타입 안전성 유지
|
||||||
|
|
||||||
|
### 성능
|
||||||
|
|
||||||
|
- ✅ 불필요한 ORM 오버헤드 제거
|
||||||
|
- ✅ 쿼리 최적화 가능
|
||||||
|
|
||||||
|
### 유지보수성
|
||||||
|
|
||||||
|
- ✅ 명확한 SQL 쿼리
|
||||||
|
- ✅ 디버깅 용이
|
||||||
|
- ✅ 데이터베이스 마이그레이션 용이
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 참고사항
|
||||||
|
|
||||||
|
### Import 변경
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// After
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 타입 정의
|
||||||
|
|
||||||
|
- 각 테이블의 타입은 `types/` 디렉토리에서 import
|
||||||
|
- 필요시 새로운 타입 정의 추가
|
||||||
|
|
||||||
|
### 에러 처리
|
||||||
|
|
||||||
|
- 기존 try-catch 구조 유지
|
||||||
|
- 적절한 HTTP 상태 코드 반환
|
||||||
|
- 사용자 친화적 에러 메시지
|
||||||
|
|
@ -0,0 +1,546 @@
|
||||||
|
# Phase 4: 남은 Prisma 호출 전환 계획
|
||||||
|
|
||||||
|
## 📊 현재 상황
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
| --------------- | -------------------------------- |
|
||||||
|
| 총 Prisma 호출 | 29개 |
|
||||||
|
| 대상 파일 | 7개 |
|
||||||
|
| **현재 진행률** | **17/29 (58.6%)** 🔄 **진행 중** |
|
||||||
|
| 복잡도 | 중간 |
|
||||||
|
| 우선순위 | 🔴 높음 (Phase 4) |
|
||||||
|
| **상태** | ⏳ **진행 중** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 파일별 현황
|
||||||
|
|
||||||
|
### ✅ 완료된 파일 (2개)
|
||||||
|
|
||||||
|
1. **adminController.ts** - ✅ **28개 완료**
|
||||||
|
|
||||||
|
- 사용자 관리: getUserList, getUserInfo, updateUserStatus, deleteUser
|
||||||
|
- 프로필 관리: getMyProfile, updateMyProfile, resetPassword
|
||||||
|
- 사용자 생성/수정: createOrUpdateUser (UPSERT)
|
||||||
|
- 회사 관리: getCompanyList, createCompany, updateCompany, deleteCompany
|
||||||
|
- 부서 관리: getDepartmentList, getDeptInfo
|
||||||
|
- 메뉴 관리: createMenu, updateMenu, deleteMenu
|
||||||
|
- 다국어: getMultiLangKeys, updateLocale
|
||||||
|
|
||||||
|
2. **screenFileController.ts** - ✅ **2개 완료**
|
||||||
|
- getScreenComponentFiles: findMany → query (LIKE)
|
||||||
|
- getComponentFiles: findMany → query (LIKE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏳ 남은 파일 (5개, 총 12개 호출)
|
||||||
|
|
||||||
|
### 1. webTypeStandardController.ts (11개) 🔴 최우선
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/controllers/webTypeStandardController.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출 목록:
|
||||||
|
|
||||||
|
1. **라인 33**: `getWebTypeStandards()` - findMany
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const webTypes = await prisma.web_type_standards.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **라인 58**: `getWebTypeStandard()` - findUnique
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const webTypeData = await prisma.web_type_standards.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **라인 112**: `createWebTypeStandard()` - findUnique (중복 체크)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||||
|
where: { web_type: webType },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **라인 123**: `createWebTypeStandard()` - create
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const newWebType = await prisma.web_type_standards.create({
|
||||||
|
data: { ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **라인 178**: `updateWebTypeStandard()` - findUnique (존재 확인)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **라인 189**: `updateWebTypeStandard()` - update
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const updatedWebType = await prisma.web_type_standards.update({
|
||||||
|
where: { id }, data: { ... }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **라인 230**: `deleteWebTypeStandard()` - findUnique (존재 확인)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingWebType = await prisma.web_type_standards.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **라인 241**: `deleteWebTypeStandard()` - delete
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.web_type_standards.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
9. **라인 275**: `updateSortOrder()` - $transaction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await prisma.$transaction(
|
||||||
|
updates.map((item) =>
|
||||||
|
prisma.web_type_standards.update({ ... })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
10. **라인 277**: `updateSortOrder()` - update (트랜잭션 내부)
|
||||||
|
|
||||||
|
11. **라인 305**: `getCategories()` - groupBy
|
||||||
|
```typescript
|
||||||
|
const categories = await prisma.web_type_standards.groupBy({
|
||||||
|
by: ["category"],
|
||||||
|
where,
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- findMany → `query<WebTypeStandard>` with dynamic WHERE
|
||||||
|
- findUnique → `queryOne<WebTypeStandard>`
|
||||||
|
- create → `queryOne` with INSERT RETURNING
|
||||||
|
- update → `queryOne` with UPDATE RETURNING
|
||||||
|
- delete → `query` with DELETE
|
||||||
|
- $transaction → `transaction` with client.query
|
||||||
|
- groupBy → `query` with GROUP BY, COUNT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. fileController.ts (1개) 🟡
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/controllers/fileController.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출:
|
||||||
|
|
||||||
|
1. **라인 726**: `downloadFile()` - findUnique
|
||||||
|
```typescript
|
||||||
|
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||||
|
where: { objid: BigInt(objid) },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- findUnique → `queryOne<AttachFileInfo>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. multiConnectionQueryService.ts (4개) 🟢
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/services/multiConnectionQueryService.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출 목록:
|
||||||
|
|
||||||
|
1. **라인 1005**: `executeSelect()` - $queryRawUnsafe
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return await prisma.$queryRawUnsafe(query, ...queryParams);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **라인 1022**: `executeInsert()` - $queryRawUnsafe
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const insertResult = await prisma.$queryRawUnsafe(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **라인 1055**: `executeUpdate()` - $queryRawUnsafe
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return await prisma.$queryRawUnsafe(updateQuery, ...updateParams);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **라인 1071**: `executeDelete()` - $queryRawUnsafe
|
||||||
|
```typescript
|
||||||
|
return await prisma.$queryRawUnsafe(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- $queryRawUnsafe → `query<any>` (이미 Raw SQL 사용 중)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. config/database.ts (4개) 🟢
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/config/database.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출:
|
||||||
|
|
||||||
|
1. **라인 1**: PrismaClient import
|
||||||
|
2. **라인 17**: prisma 인스턴스 생성
|
||||||
|
3. **라인 22**: `await prisma.$connect()`
|
||||||
|
4. **라인 31, 35, 40**: `await prisma.$disconnect()`
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- 이 파일은 데이터베이스 설정 파일이므로 완전히 제거
|
||||||
|
- 기존 `db.ts`의 connection pool로 대체
|
||||||
|
- 모든 import 경로를 `database` → `database/db`로 변경
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. routes/ddlRoutes.ts (2개) 🟢
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/routes/ddlRoutes.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출:
|
||||||
|
|
||||||
|
1. **라인 183-184**: 동적 PrismaClient import
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { PrismaClient } = await import("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **라인 186-187**: 연결 테스트
|
||||||
|
```typescript
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
await prisma.$disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- 동적 import 제거
|
||||||
|
- `query('SELECT 1')` 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. routes/companyManagementRoutes.ts (2개) 🟢
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/routes/companyManagementRoutes.ts`
|
||||||
|
|
||||||
|
#### Prisma 호출:
|
||||||
|
|
||||||
|
1. **라인 32**: findUnique (중복 체크)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const existingCompany = await prisma.company_mng.findUnique({
|
||||||
|
where: { company_code },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **라인 61**: update (회사명 업데이트)
|
||||||
|
```typescript
|
||||||
|
await prisma.company_mng.update({
|
||||||
|
where: { company_code },
|
||||||
|
data: { company_name },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**전환 전략**:
|
||||||
|
|
||||||
|
- findUnique → `queryOne`
|
||||||
|
- update → `query`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. tests/authService.test.ts (2개) ⚠️
|
||||||
|
|
||||||
|
**위치**: `backend-node/src/tests/authService.test.ts`
|
||||||
|
|
||||||
|
테스트 파일은 별도 처리 필요 (Phase 5에서 처리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 전환 우선순위
|
||||||
|
|
||||||
|
### Phase 4.1: 컨트롤러 (완료)
|
||||||
|
|
||||||
|
- [x] screenFileController.ts (2개)
|
||||||
|
- [x] adminController.ts (28개)
|
||||||
|
|
||||||
|
### Phase 4.2: 남은 컨트롤러 (진행 예정)
|
||||||
|
|
||||||
|
- [ ] webTypeStandardController.ts (11개) - 🔴 최우선
|
||||||
|
- [ ] fileController.ts (1개)
|
||||||
|
|
||||||
|
### Phase 4.3: Routes (진행 예정)
|
||||||
|
|
||||||
|
- [ ] ddlRoutes.ts (2개)
|
||||||
|
- [ ] companyManagementRoutes.ts (2개)
|
||||||
|
|
||||||
|
### Phase 4.4: Services (진행 예정)
|
||||||
|
|
||||||
|
- [ ] multiConnectionQueryService.ts (4개)
|
||||||
|
|
||||||
|
### Phase 4.5: Config (진행 예정)
|
||||||
|
|
||||||
|
- [ ] database.ts (4개) - 전체 파일 제거
|
||||||
|
|
||||||
|
### Phase 4.6: Tests (Phase 5)
|
||||||
|
|
||||||
|
- [ ] authService.test.ts (2개) - 별도 처리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 체크리스트
|
||||||
|
|
||||||
|
### webTypeStandardController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] getWebTypeStandards (findMany → query)
|
||||||
|
- [ ] getWebTypeStandard (findUnique → queryOne)
|
||||||
|
- [ ] createWebTypeStandard (findUnique + create → queryOne)
|
||||||
|
- [ ] updateWebTypeStandard (findUnique + update → queryOne)
|
||||||
|
- [ ] deleteWebTypeStandard (findUnique + delete → query)
|
||||||
|
- [ ] updateSortOrder ($transaction → transaction)
|
||||||
|
- [ ] getCategories (groupBy → query with GROUP BY)
|
||||||
|
- [ ] TypeScript 컴파일 확인
|
||||||
|
- [ ] Linter 오류 확인
|
||||||
|
- [ ] 동작 테스트
|
||||||
|
|
||||||
|
### fileController.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] queryOne import 추가
|
||||||
|
- [ ] downloadFile (findUnique → queryOne)
|
||||||
|
- [ ] TypeScript 컴파일 확인
|
||||||
|
|
||||||
|
### routes/ddlRoutes.ts
|
||||||
|
|
||||||
|
- [ ] 동적 PrismaClient import 제거
|
||||||
|
- [ ] query import 추가
|
||||||
|
- [ ] 연결 테스트 로직 변경
|
||||||
|
- [ ] TypeScript 컴파일 확인
|
||||||
|
|
||||||
|
### routes/companyManagementRoutes.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query, queryOne import 추가
|
||||||
|
- [ ] findUnique → queryOne
|
||||||
|
- [ ] update → query
|
||||||
|
- [ ] TypeScript 컴파일 확인
|
||||||
|
|
||||||
|
### services/multiConnectionQueryService.ts
|
||||||
|
|
||||||
|
- [ ] Prisma import 제거
|
||||||
|
- [ ] query import 추가
|
||||||
|
- [ ] $queryRawUnsafe → query (4곳)
|
||||||
|
- [ ] TypeScript 컴파일 확인
|
||||||
|
|
||||||
|
### config/database.ts
|
||||||
|
|
||||||
|
- [ ] 파일 전체 분석
|
||||||
|
- [ ] 의존성 확인
|
||||||
|
- [ ] 대체 방안 구현
|
||||||
|
- [ ] 모든 import 경로 변경
|
||||||
|
- [ ] 파일 삭제 또는 완전 재작성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 전환 패턴 요약
|
||||||
|
|
||||||
|
### 1. findMany → query
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const items = await prisma.table.findMany({ where, orderBy });
|
||||||
|
|
||||||
|
// After
|
||||||
|
const items = await query<T>(
|
||||||
|
`SELECT * FROM table WHERE ... ORDER BY ...`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. findUnique → queryOne
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const item = await prisma.table.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
// After
|
||||||
|
const item = await queryOne<T>(`SELECT * FROM table WHERE id = $1`, [id]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. create → queryOne with RETURNING
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const newItem = await prisma.table.create({ data });
|
||||||
|
|
||||||
|
// After
|
||||||
|
const [newItem] = await query<T>(
|
||||||
|
`INSERT INTO table (col1, col2) VALUES ($1, $2) RETURNING *`,
|
||||||
|
[val1, val2]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. update → query with RETURNING
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const updated = await prisma.table.update({ where, data });
|
||||||
|
|
||||||
|
// After
|
||||||
|
const [updated] = await query<T>(
|
||||||
|
`UPDATE table SET col1 = $1 WHERE id = $2 RETURNING *`,
|
||||||
|
[val1, id]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. delete → query
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
await prisma.table.delete({ where: { id } });
|
||||||
|
|
||||||
|
// After
|
||||||
|
await query(`DELETE FROM table WHERE id = $1`, [id]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. $transaction → transaction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.table.update({ ... }),
|
||||||
|
prisma.table.update({ ... })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// After
|
||||||
|
await transaction(async (client) => {
|
||||||
|
await client.query(`UPDATE table SET ...`, params1);
|
||||||
|
await client.query(`UPDATE table SET ...`, params2);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. groupBy → query with GROUP BY
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before
|
||||||
|
const result = await prisma.table.groupBy({
|
||||||
|
by: ["category"],
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// After
|
||||||
|
const result = await query<T>(
|
||||||
|
`SELECT category, COUNT(*) as count FROM table GROUP BY category`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 진행 상황
|
||||||
|
|
||||||
|
### 전체 진행률: 17/29 (58.6%)
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1-3: Service Layer ████████████████████████████ 100% (415/415)
|
||||||
|
Phase 4.1: Controllers ████████████████████████████ 100% (30/30)
|
||||||
|
Phase 4.2: 남은 파일 ███████░░░░░░░░░░░░░░░░░░░░ 58% (17/29)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상세 진행 상황
|
||||||
|
|
||||||
|
| 카테고리 | 완료 | 남음 | 진행률 |
|
||||||
|
| ----------- | ---- | ---- | ------ |
|
||||||
|
| Services | 415 | 0 | 100% |
|
||||||
|
| Controllers | 30 | 11 | 73% |
|
||||||
|
| Routes | 0 | 4 | 0% |
|
||||||
|
| Config | 0 | 4 | 0% |
|
||||||
|
| **총계** | 445 | 19 | 95.9% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎬 다음 단계
|
||||||
|
|
||||||
|
1. **webTypeStandardController.ts 전환** (11개)
|
||||||
|
|
||||||
|
- 가장 많은 Prisma 호출을 가진 남은 컨트롤러
|
||||||
|
- 웹 타입 표준 관리 핵심 기능
|
||||||
|
|
||||||
|
2. **fileController.ts 전환** (1개)
|
||||||
|
|
||||||
|
- 단순 findUnique만 있어 빠르게 처리 가능
|
||||||
|
|
||||||
|
3. **Routes 전환** (4개)
|
||||||
|
|
||||||
|
- ddlRoutes.ts
|
||||||
|
- companyManagementRoutes.ts
|
||||||
|
|
||||||
|
4. **Service 전환** (4개)
|
||||||
|
|
||||||
|
- multiConnectionQueryService.ts
|
||||||
|
|
||||||
|
5. **Config 제거** (4개)
|
||||||
|
- database.ts 완전 제거 또는 재작성
|
||||||
|
- 모든 의존성 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 주의사항
|
||||||
|
|
||||||
|
1. **database.ts 처리**
|
||||||
|
|
||||||
|
- 현재 많은 파일이 `import prisma from '../config/database'` 사용
|
||||||
|
- 모든 import를 `import { query, queryOne } from '../database/db'`로 변경 필요
|
||||||
|
- 단계적으로 진행하여 빌드 오류 방지
|
||||||
|
|
||||||
|
2. **BigInt 처리**
|
||||||
|
|
||||||
|
- fileController의 `objid: BigInt(objid)` → `objid::bigint` 또는 `CAST(objid AS BIGINT)`
|
||||||
|
|
||||||
|
3. **트랜잭션 처리**
|
||||||
|
|
||||||
|
- webTypeStandardController의 `updateSortOrder`는 복잡한 트랜잭션
|
||||||
|
- `transaction` 함수 사용 필요
|
||||||
|
|
||||||
|
4. **타입 안전성**
|
||||||
|
- 모든 Raw Query에 명시적 타입 지정 필요
|
||||||
|
- `query<WebTypeStandard>`, `queryOne<AttachFileInfo>` 등
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 완료 후 작업
|
||||||
|
|
||||||
|
- [ ] 전체 컴파일 확인
|
||||||
|
- [ ] Linter 오류 해결
|
||||||
|
- [ ] 통합 테스트 실행
|
||||||
|
- [ ] Prisma 관련 의존성 완전 제거 (package.json)
|
||||||
|
- [ ] `prisma/` 디렉토리 정리
|
||||||
|
- [ ] 문서 업데이트
|
||||||
|
- [ ] 커밋 및 Push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-10-01
|
||||||
|
**최종 업데이트**: 2025-10-01
|
||||||
|
**상태**: 🔄 진행 중 (58.6% 완료)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,604 @@
|
||||||
|
# ERP 시스템 UI/UX 디자인 가이드
|
||||||
|
|
||||||
|
## 📋 문서 목적
|
||||||
|
이 문서는 ERP 시스템의 새로운 페이지나 컴포넌트를 개발할 때 참고할 수 있는 **디자인 시스템 기준안**입니다.
|
||||||
|
일관된 사용자 경험을 위해 모든 개발자는 이 가이드를 따라 개발해주세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 디자인 시스템 개요
|
||||||
|
|
||||||
|
### 디자인 철학
|
||||||
|
- **일관성**: 모든 페이지에서 동일한 패턴 사용
|
||||||
|
- **명확성**: 직관적이고 이해하기 쉬운 UI
|
||||||
|
- **접근성**: 모든 사용자가 쉽게 사용할 수 있도록
|
||||||
|
- **반응성**: 다양한 화면 크기에 대응
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
- **CSS Framework**: Tailwind CSS
|
||||||
|
- **UI Library**: shadcn/ui
|
||||||
|
- **Icons**: Lucide React
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📐 페이지 기본 구조
|
||||||
|
|
||||||
|
### 1. 표준 페이지 레이아웃
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function YourPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
{/* 페이지 제목 */}
|
||||||
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">페이지 제목</h1>
|
||||||
|
<p className="mt-2 text-gray-600">페이지 설명</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* 버튼들 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* 내용 */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 구조 설명
|
||||||
|
|
||||||
|
#### 최상위 래퍼
|
||||||
|
```tsx
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
```
|
||||||
|
- `min-h-screen`: 최소 높이를 화면 전체로
|
||||||
|
- `bg-gray-50`: 연한 회색 배경 (전체 페이지 기본 배경)
|
||||||
|
|
||||||
|
#### 컨테이너
|
||||||
|
```tsx
|
||||||
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
```
|
||||||
|
- `w-full max-w-none`: 전체 너비 사용
|
||||||
|
- `px-4`: 좌우 패딩 1rem (16px)
|
||||||
|
- `py-8`: 상하 패딩 2rem (32px)
|
||||||
|
- `space-y-8`: 하위 요소 간 수직 간격 2rem
|
||||||
|
|
||||||
|
#### 헤더 카드
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">제목</h1>
|
||||||
|
<p className="mt-2 text-gray-600">설명</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* 버튼들 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 컴포넌트 디자인 기준
|
||||||
|
|
||||||
|
### 1. 버튼
|
||||||
|
|
||||||
|
#### 주요 버튼 (Primary)
|
||||||
|
```tsx
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
버튼 텍스트
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 보조 버튼 (Secondary)
|
||||||
|
```tsx
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 위험 버튼 (Danger)
|
||||||
|
```tsx
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 카드 (Card)
|
||||||
|
|
||||||
|
#### 기본 카드
|
||||||
|
```tsx
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>카드 제목</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
{/* 내용 */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 강조 카드
|
||||||
|
```tsx
|
||||||
|
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center">
|
||||||
|
<Icon className="w-5 h-5 mr-2 text-orange-500" />
|
||||||
|
제목
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-700">내용</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 테이블
|
||||||
|
|
||||||
|
#### 기본 테이블 구조
|
||||||
|
```tsx
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||||
|
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||||
|
컬럼명
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60">
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
데이터
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 폼 (Form)
|
||||||
|
|
||||||
|
#### 입력 필드
|
||||||
|
```tsx
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
라벨
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all duration-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 셀렉트
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">옵션 1</SelectItem>
|
||||||
|
<SelectItem value="2">옵션 2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 빈 상태 (Empty State)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="text-center py-16 bg-white shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Icon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-gray-500 mb-4">데이터가 없습니다</p>
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
추가하기
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 로딩 상태
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="flex justify-center items-center py-16">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 색상 시스템
|
||||||
|
|
||||||
|
### 주 색상 (Primary)
|
||||||
|
```css
|
||||||
|
orange-50 #fff7ed /* 매우 연한 배경 */
|
||||||
|
orange-100 #ffedd5 /* 연한 배경 */
|
||||||
|
orange-500 #f97316 /* 주요 버튼, 강조 */
|
||||||
|
orange-600 #ea580c /* 버튼 호버 */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회색 (Gray)
|
||||||
|
```css
|
||||||
|
gray-50 #f9fafb /* 페이지 배경 */
|
||||||
|
gray-100 #f3f4f6 /* 카드 내부 구분 */
|
||||||
|
gray-200 #e5e7eb /* 테두리 */
|
||||||
|
gray-300 #d1d5db /* 입력 필드 테두리 */
|
||||||
|
gray-500 #6b7280 /* 보조 텍스트 */
|
||||||
|
gray-600 #4b5563 /* 일반 텍스트 */
|
||||||
|
gray-700 #374151 /* 라벨, 헤더 */
|
||||||
|
gray-800 #1f2937 /* 제목 */
|
||||||
|
gray-900 #111827 /* 주요 제목 */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상태 색상
|
||||||
|
```css
|
||||||
|
/* 성공 */
|
||||||
|
green-100 #dcfce7
|
||||||
|
green-500 #22c55e
|
||||||
|
green-700 #15803d
|
||||||
|
|
||||||
|
/* 경고 */
|
||||||
|
red-100 #fee2e2
|
||||||
|
red-500 #ef4444
|
||||||
|
red-600 #dc2626
|
||||||
|
|
||||||
|
/* 정보 */
|
||||||
|
blue-50 #eff6ff
|
||||||
|
blue-100 #dbeafe
|
||||||
|
blue-500 #3b82f6
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📏 간격 시스템
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
```css
|
||||||
|
space-y-2 0.5rem (8px) /* 폼 요소 간 간격 */
|
||||||
|
space-y-4 1rem (16px) /* 섹션 내부 간격 */
|
||||||
|
space-y-6 1.5rem (24px) /* 카드 내부 큰 간격 */
|
||||||
|
space-y-8 2rem (32px) /* 페이지 주요 섹션 간격 */
|
||||||
|
|
||||||
|
gap-2 0.5rem (8px) /* 버튼 그룹 간격 */
|
||||||
|
gap-4 1rem (16px) /* 카드 그리드 간격 */
|
||||||
|
gap-6 1.5rem (24px) /* 큰 카드 그리드 간격 */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Padding
|
||||||
|
```css
|
||||||
|
p-2 0.5rem (8px) /* 작은 요소 */
|
||||||
|
p-4 1rem (16px) /* 일반 요소 */
|
||||||
|
p-6 1.5rem (24px) /* 카드, 헤더 */
|
||||||
|
p-8 2rem (32px) /* 큰 영역 */
|
||||||
|
|
||||||
|
px-3 좌우 0.75rem /* 입력 필드 */
|
||||||
|
px-4 좌우 1rem /* 버튼 */
|
||||||
|
px-6 좌우 1.5rem /* 테이블 셀 */
|
||||||
|
|
||||||
|
py-2 상하 0.5rem /* 버튼 */
|
||||||
|
py-4 상하 1rem /* 입력 필드 */
|
||||||
|
py-8 상하 2rem /* 페이지 컨테이너 */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 타이포그래피
|
||||||
|
|
||||||
|
### 제목 (Headings)
|
||||||
|
```css
|
||||||
|
/* 페이지 제목 */
|
||||||
|
text-3xl font-bold text-gray-900
|
||||||
|
/* 예: 30px, Bold, #111827 */
|
||||||
|
|
||||||
|
/* 섹션 제목 */
|
||||||
|
text-2xl font-bold text-gray-900
|
||||||
|
/* 예: 24px, Bold */
|
||||||
|
|
||||||
|
/* 카드 제목 */
|
||||||
|
text-lg font-semibold text-gray-800
|
||||||
|
/* 예: 18px, Semi-bold */
|
||||||
|
|
||||||
|
/* 작은 제목 */
|
||||||
|
text-base font-medium text-gray-700
|
||||||
|
/* 예: 16px, Medium */
|
||||||
|
```
|
||||||
|
|
||||||
|
### 본문 (Body Text)
|
||||||
|
```css
|
||||||
|
/* 일반 텍스트 */
|
||||||
|
text-sm text-gray-600
|
||||||
|
/* 14px, #4b5563 */
|
||||||
|
|
||||||
|
/* 보조 설명 */
|
||||||
|
text-sm text-gray-500
|
||||||
|
/* 14px, #6b7280 */
|
||||||
|
|
||||||
|
/* 라벨 */
|
||||||
|
text-sm font-medium text-gray-700
|
||||||
|
/* 14px, Medium */
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎭 인터랙션 패턴
|
||||||
|
|
||||||
|
### 호버 효과
|
||||||
|
```css
|
||||||
|
/* 버튼 호버 */
|
||||||
|
hover:bg-orange-600
|
||||||
|
hover:shadow-md
|
||||||
|
|
||||||
|
/* 카드 호버 */
|
||||||
|
hover:shadow-lg transition-shadow
|
||||||
|
|
||||||
|
/* 테이블 행 호버 */
|
||||||
|
hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60
|
||||||
|
```
|
||||||
|
|
||||||
|
### 포커스 효과
|
||||||
|
```css
|
||||||
|
/* 입력 필드 포커스 */
|
||||||
|
focus:outline-none
|
||||||
|
focus:ring-2
|
||||||
|
focus:ring-orange-500
|
||||||
|
focus:border-orange-500
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전환 효과
|
||||||
|
```css
|
||||||
|
/* 일반 전환 */
|
||||||
|
transition-all duration-200
|
||||||
|
|
||||||
|
/* 그림자 전환 */
|
||||||
|
transition-shadow
|
||||||
|
|
||||||
|
/* 색상 전환 */
|
||||||
|
transition-colors duration-200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔲 그리드 시스템
|
||||||
|
|
||||||
|
### 반응형 그리드
|
||||||
|
```tsx
|
||||||
|
{/* 1열 → 2열 → 3열 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{/* 카드들 */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1열 → 2열 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* 항목들 */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브레이크포인트
|
||||||
|
```css
|
||||||
|
sm: 640px @media (min-width: 640px)
|
||||||
|
md: 768px @media (min-width: 768px)
|
||||||
|
lg: 1024px @media (min-width: 1024px)
|
||||||
|
xl: 1280px @media (min-width: 1280px)
|
||||||
|
2xl: 1536px @media (min-width: 1536px)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 실전 예제
|
||||||
|
|
||||||
|
### 예제 1: 관리 페이지 (데이터 있음)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function ManagementPage() {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||||
|
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={loadData}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
새로 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-500">총 개수</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">156</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-100 p-3 rounded-lg">
|
||||||
|
<Database className="w-6 h-6 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/* 나머지 통계 카드들... */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||||
|
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||||
|
이름
|
||||||
|
</th>
|
||||||
|
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||||
|
상태
|
||||||
|
</th>
|
||||||
|
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||||
|
작업
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-600">
|
||||||
|
{item.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
|
||||||
|
활성
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="text-red-500">
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예제 2: 빈 상태 페이지
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function EmptyStatePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">데이터 관리</h1>
|
||||||
|
<p className="mt-2 text-gray-600">시스템 데이터를 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
새로 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
<Card className="text-center py-16 bg-white shadow-sm">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<Database className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-gray-500 mb-4">아직 등록된 데이터가 없습니다</p>
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
첫 데이터 추가하기
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 안내 정보 */}
|
||||||
|
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center">
|
||||||
|
<Info className="w-5 h-5 mr-2 text-orange-500" />
|
||||||
|
데이터 관리 안내
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
💡 데이터를 추가하여 시스템을 사용해보세요!
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-orange-500 mr-2">✓</span>
|
||||||
|
<span>기능 설명 1</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-orange-500 mr-2">✓</span>
|
||||||
|
<span>기능 설명 2</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
### 새 페이지 만들 때
|
||||||
|
- [ ] `min-h-screen bg-gray-50` 래퍼 사용
|
||||||
|
- [ ] 헤더 카드 (`bg-white rounded-lg shadow-sm border p-6`) 포함
|
||||||
|
- [ ] 제목은 `text-3xl font-bold text-gray-900`
|
||||||
|
- [ ] 설명은 `mt-2 text-gray-600`
|
||||||
|
- [ ] 주요 버튼은 `bg-orange-500 hover:bg-orange-600`
|
||||||
|
- [ ] 카드는 `shadow-sm` 클래스 포함
|
||||||
|
- [ ] 간격은 `space-y-8` 사용
|
||||||
|
|
||||||
|
### 새 컴포넌트 만들 때
|
||||||
|
- [ ] 일관된 패딩 사용 (`p-4`, `p-6`)
|
||||||
|
- [ ] 호버 효과 추가
|
||||||
|
- [ ] 전환 애니메이션 적용 (`transition-all duration-200`)
|
||||||
|
- [ ] 적절한 아이콘 사용 (Lucide React)
|
||||||
|
- [ ] 반응형 디자인 고려 (`md:`, `lg:`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 자료
|
||||||
|
|
||||||
|
### Tailwind CSS 공식 문서
|
||||||
|
- https://tailwindcss.com/docs
|
||||||
|
|
||||||
|
### shadcn/ui 컴포넌트
|
||||||
|
- https://ui.shadcn.com/
|
||||||
|
|
||||||
|
### Lucide 아이콘
|
||||||
|
- https://lucide.dev/icons/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||||
|
|
@ -15,9 +15,6 @@ RUN npm ci
|
||||||
# 소스 코드 복사
|
# 소스 코드 복사
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Prisma 클라이언트 생성
|
|
||||||
RUN npx prisma generate
|
|
||||||
|
|
||||||
# 개발 환경 설정
|
# 개발 환경 설정
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,418 @@
|
||||||
|
# Phase 1: Raw Query 기반 구조 사용 가이드
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
Phase 1에서 구현한 Raw Query 기반 데이터베이스 아키텍처 사용 방법입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 구현된 모듈
|
||||||
|
|
||||||
|
### 1. **DatabaseManager** (`src/database/db.ts`)
|
||||||
|
|
||||||
|
PostgreSQL 연결 풀 기반 핵심 모듈
|
||||||
|
|
||||||
|
**주요 함수:**
|
||||||
|
- `query<T>(sql, params)` - 기본 쿼리 실행
|
||||||
|
- `queryOne<T>(sql, params)` - 단일 행 조회
|
||||||
|
- `transaction(callback)` - 트랜잭션 실행
|
||||||
|
- `getPool()` - 연결 풀 가져오기
|
||||||
|
- `getPoolStatus()` - 연결 풀 상태 확인
|
||||||
|
|
||||||
|
### 2. **QueryBuilder** (`src/utils/queryBuilder.ts`)
|
||||||
|
|
||||||
|
동적 쿼리 생성 유틸리티
|
||||||
|
|
||||||
|
**주요 메서드:**
|
||||||
|
- `QueryBuilder.select(tableName, options)` - SELECT 쿼리
|
||||||
|
- `QueryBuilder.insert(tableName, data, options)` - INSERT 쿼리
|
||||||
|
- `QueryBuilder.update(tableName, data, where, options)` - UPDATE 쿼리
|
||||||
|
- `QueryBuilder.delete(tableName, where, options)` - DELETE 쿼리
|
||||||
|
- `QueryBuilder.count(tableName, where)` - COUNT 쿼리
|
||||||
|
- `QueryBuilder.exists(tableName, where)` - EXISTS 쿼리
|
||||||
|
|
||||||
|
### 3. **DatabaseValidator** (`src/utils/databaseValidator.ts`)
|
||||||
|
|
||||||
|
SQL Injection 방지 및 입력 검증
|
||||||
|
|
||||||
|
**주요 메서드:**
|
||||||
|
- `validateTableName(tableName)` - 테이블명 검증
|
||||||
|
- `validateColumnName(columnName)` - 컬럼명 검증
|
||||||
|
- `validateWhereClause(where)` - WHERE 조건 검증
|
||||||
|
- `sanitizeInput(input)` - 입력 값 Sanitize
|
||||||
|
|
||||||
|
### 4. **타입 정의** (`src/types/database.ts`)
|
||||||
|
|
||||||
|
TypeScript 타입 안전성 보장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 사용 예제
|
||||||
|
|
||||||
|
### 1. 기본 쿼리 실행
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query, queryOne } from '../database/db';
|
||||||
|
|
||||||
|
// 여러 행 조회
|
||||||
|
const users = await query<User>(
|
||||||
|
'SELECT * FROM users WHERE status = $1',
|
||||||
|
['active']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 단일 행 조회
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
'SELECT * FROM users WHERE user_id = $1',
|
||||||
|
['user123']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('사용자를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. QueryBuilder 사용
|
||||||
|
|
||||||
|
#### SELECT
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { QueryBuilder } from '../utils/queryBuilder';
|
||||||
|
|
||||||
|
// 기본 SELECT
|
||||||
|
const { query: sql, params } = QueryBuilder.select('users', {
|
||||||
|
where: { status: 'active' },
|
||||||
|
orderBy: 'created_at DESC',
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = await query(sql, params);
|
||||||
|
|
||||||
|
// 복잡한 SELECT (JOIN, WHERE, ORDER BY)
|
||||||
|
const { query: sql2, params: params2 } = QueryBuilder.select('users', {
|
||||||
|
columns: ['users.user_id', 'users.username', 'departments.dept_name'],
|
||||||
|
joins: [
|
||||||
|
{
|
||||||
|
type: 'LEFT',
|
||||||
|
table: 'departments',
|
||||||
|
on: 'users.dept_id = departments.dept_id',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
where: { 'users.status': 'active' },
|
||||||
|
orderBy: ['users.created_at DESC', 'users.username ASC'],
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await query(sql2, params2);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### INSERT
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { QueryBuilder } from '../utils/queryBuilder';
|
||||||
|
|
||||||
|
// 기본 INSERT
|
||||||
|
const { query: sql, params } = QueryBuilder.insert(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
user_id: 'new_user',
|
||||||
|
username: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
returning: ['id', 'user_id'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newUser] = await query(sql, params);
|
||||||
|
console.log('생성된 사용자 ID:', newUser.id);
|
||||||
|
|
||||||
|
// UPSERT (INSERT ... ON CONFLICT)
|
||||||
|
const { query: sql2, params: params2 } = QueryBuilder.insert(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
user_id: 'user123',
|
||||||
|
username: 'Jane',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onConflict: {
|
||||||
|
columns: ['user_id'],
|
||||||
|
action: 'DO UPDATE',
|
||||||
|
updateSet: ['username', 'email'],
|
||||||
|
},
|
||||||
|
returning: ['*'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [upsertedUser] = await query(sql2, params2);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UPDATE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { QueryBuilder } from '../utils/queryBuilder';
|
||||||
|
|
||||||
|
const { query: sql, params } = QueryBuilder.update(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
username: 'Updated Name',
|
||||||
|
email: 'updated@example.com',
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user_id: 'user123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
returning: ['*'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updatedUser] = await query(sql, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DELETE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { QueryBuilder } from '../utils/queryBuilder';
|
||||||
|
|
||||||
|
const { query: sql, params } = QueryBuilder.delete(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
user_id: 'user_to_delete',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
returning: ['user_id', 'username'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const [deletedUser] = await query(sql, params);
|
||||||
|
console.log('삭제된 사용자:', deletedUser.username);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 트랜잭션 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { transaction } from '../database/db';
|
||||||
|
|
||||||
|
// 복잡한 트랜잭션 처리
|
||||||
|
const result = await transaction(async (client) => {
|
||||||
|
// 1. 사용자 생성
|
||||||
|
const userResult = await client.query(
|
||||||
|
'INSERT INTO users (user_id, username, email) VALUES ($1, $2, $3) RETURNING id',
|
||||||
|
['new_user', 'John', 'john@example.com']
|
||||||
|
);
|
||||||
|
|
||||||
|
const userId = userResult.rows[0].id;
|
||||||
|
|
||||||
|
// 2. 역할 할당
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)',
|
||||||
|
[userId, 'admin']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. 로그 생성
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO audit_logs (action, user_id, details) VALUES ($1, $2, $3)',
|
||||||
|
['USER_CREATED', userId, JSON.stringify({ username: 'John' })]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true, userId };
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('트랜잭션 완료:', result);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. JSON 필드 쿼리 (JSONB)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { QueryBuilder } from '../utils/queryBuilder';
|
||||||
|
|
||||||
|
// JSON 필드 쿼리 (config->>'type' = 'form')
|
||||||
|
const { query: sql, params } = QueryBuilder.select('screen_management', {
|
||||||
|
columns: ['*'],
|
||||||
|
where: {
|
||||||
|
company_code: 'COMPANY_001',
|
||||||
|
"config->>'type'": 'form',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const screens = await query(sql, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 동적 테이블 쿼리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||||
|
|
||||||
|
async function queryDynamicTable(tableName: string, filters: Record<string, any>) {
|
||||||
|
// 테이블명 검증 (SQL Injection 방지)
|
||||||
|
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||||
|
throw new Error('유효하지 않은 테이블명입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHERE 조건 검증
|
||||||
|
if (!DatabaseValidator.validateWhereClause(filters)) {
|
||||||
|
throw new Error('유효하지 않은 WHERE 조건입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { query: sql, params } = QueryBuilder.select(tableName, {
|
||||||
|
where: filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용 예
|
||||||
|
const data = await queryDynamicTable('company_data_001', {
|
||||||
|
status: 'active',
|
||||||
|
region: 'Seoul',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 보안 고려사항
|
||||||
|
|
||||||
|
### 1. **항상 Parameterized Query 사용**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 위험: SQL Injection 취약
|
||||||
|
const userId = req.params.userId;
|
||||||
|
const sql = `SELECT * FROM users WHERE user_id = '${userId}'`;
|
||||||
|
const users = await query(sql);
|
||||||
|
|
||||||
|
// ✅ 안전: Parameterized Query
|
||||||
|
const userId = req.params.userId;
|
||||||
|
const users = await query('SELECT * FROM users WHERE user_id = $1', [userId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **식별자 검증**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||||
|
|
||||||
|
// 테이블명/컬럼명 검증
|
||||||
|
if (!DatabaseValidator.validateTableName(tableName)) {
|
||||||
|
throw new Error('유효하지 않은 테이블명입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DatabaseValidator.validateColumnName(columnName)) {
|
||||||
|
throw new Error('유효하지 않은 컬럼명입니다.');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **입력 값 Sanitize**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DatabaseValidator } from '../utils/databaseValidator';
|
||||||
|
|
||||||
|
const sanitizedData = DatabaseValidator.sanitizeInput(userInput);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 성능 최적화 팁
|
||||||
|
|
||||||
|
### 1. **연결 풀 모니터링**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getPoolStatus } from '../database/db';
|
||||||
|
|
||||||
|
const status = getPoolStatus();
|
||||||
|
console.log('연결 풀 상태:', {
|
||||||
|
total: status.totalCount,
|
||||||
|
idle: status.idleCount,
|
||||||
|
waiting: status.waitingCount,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **배치 INSERT**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { transaction } from '../database/db';
|
||||||
|
|
||||||
|
// 대량 데이터 삽입 시 트랜잭션 사용
|
||||||
|
await transaction(async (client) => {
|
||||||
|
for (const item of largeDataset) {
|
||||||
|
await client.query('INSERT INTO items (name, value) VALUES ($1, $2)', [
|
||||||
|
item.name,
|
||||||
|
item.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. **인덱스 활용 쿼리**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WHERE 절에 인덱스 컬럼 사용
|
||||||
|
const { query: sql, params } = QueryBuilder.select('users', {
|
||||||
|
where: {
|
||||||
|
user_id: 'user123', // 인덱스 컬럼
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 테스트 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 테스트 실행
|
||||||
|
npm test -- database.test.ts
|
||||||
|
|
||||||
|
# 특정 테스트만 실행
|
||||||
|
npm test -- database.test.ts -t "QueryBuilder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 에러 핸들링
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { query } from '../database/db';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const users = await query('SELECT * FROM users WHERE status = $1', ['active']);
|
||||||
|
return users;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('쿼리 실행 실패:', error.message);
|
||||||
|
|
||||||
|
// PostgreSQL 에러 코드 확인
|
||||||
|
if (error.code === '23505') {
|
||||||
|
throw new Error('중복된 값이 존재합니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === '23503') {
|
||||||
|
throw new Error('외래 키 제약 조건 위반입니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 다음 단계 (Phase 2)
|
||||||
|
|
||||||
|
Phase 1 기반 구조가 완성되었으므로, Phase 2에서는:
|
||||||
|
|
||||||
|
1. **screenManagementService.ts** 전환 (46개 호출)
|
||||||
|
2. **tableManagementService.ts** 전환 (35개 호출)
|
||||||
|
3. **dataflowService.ts** 전환 (31개 호출)
|
||||||
|
|
||||||
|
등 핵심 서비스를 Raw Query로 전환합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성일**: 2025-09-30
|
||||||
|
**버전**: 1.0.0
|
||||||
|
**담당**: Backend Development Team
|
||||||
|
|
@ -7,8 +7,7 @@ Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백
|
||||||
- **Runtime**: Node.js ^20.10.0
|
- **Runtime**: Node.js ^20.10.0
|
||||||
- **Framework**: Express ^4.18.2
|
- **Framework**: Express ^4.18.2
|
||||||
- **Language**: TypeScript ^5.3.3
|
- **Language**: TypeScript ^5.3.3
|
||||||
- **ORM**: Prisma ^5.7.1
|
- **Database**: PostgreSQL ^8.11.3 (Raw Query with `pg`)
|
||||||
- **Database**: PostgreSQL ^8.11.3
|
|
||||||
- **Authentication**: JWT + Passport
|
- **Authentication**: JWT + Passport
|
||||||
- **Testing**: Jest + Supertest
|
- **Testing**: Jest + Supertest
|
||||||
|
|
||||||
|
|
@ -17,9 +16,9 @@ Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백
|
||||||
```
|
```
|
||||||
backend-node/
|
backend-node/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── config/ # 설정 파일
|
│ ├── database/ # 데이터베이스 유틸리티
|
||||||
│ │ ├── environment.ts
|
│ │ ├── db.ts # PostgreSQL Raw Query 헬퍼
|
||||||
│ │ └── database.ts
|
│ │ └── ...
|
||||||
│ ├── controllers/ # HTTP 요청 처리
|
│ ├── controllers/ # HTTP 요청 처리
|
||||||
│ ├── services/ # 비즈니스 로직
|
│ ├── services/ # 비즈니스 로직
|
||||||
│ ├── middleware/ # Express 미들웨어
|
│ ├── middleware/ # Express 미들웨어
|
||||||
|
|
@ -30,9 +29,6 @@ backend-node/
|
||||||
│ │ └── common.ts
|
│ │ └── common.ts
|
||||||
│ ├── validators/ # 입력 검증 스키마
|
│ ├── validators/ # 입력 검증 스키마
|
||||||
│ └── app.ts # 애플리케이션 진입점
|
│ └── app.ts # 애플리케이션 진입점
|
||||||
├── prisma/
|
|
||||||
│ └── schema.prisma # 데이터베이스 스키마
|
|
||||||
├── tests/ # 테스트 파일
|
|
||||||
├── logs/ # 로그 파일
|
├── logs/ # 로그 파일
|
||||||
├── package.json
|
├── package.json
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
|
|
@ -59,13 +55,7 @@ PORT=8080
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Prisma 클라이언트 생성
|
### 3. 개발 서버 실행
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 개발 서버 실행
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
@ -80,7 +70,7 @@ npm start
|
||||||
|
|
||||||
## 📊 데이터베이스 스키마
|
## 📊 데이터베이스 스키마
|
||||||
|
|
||||||
기존 PostgreSQL 데이터베이스 스키마를 참고하여 Prisma 스키마를 설계했습니다.
|
PostgreSQL 데이터베이스를 직접 Raw Query로 사용합니다.
|
||||||
|
|
||||||
### 핵심 테이블
|
### 핵심 테이블
|
||||||
|
|
||||||
|
|
@ -146,7 +136,6 @@ npm run test:watch
|
||||||
- `npm test` - 테스트 실행
|
- `npm test` - 테스트 실행
|
||||||
- `npm run lint` - ESLint 검사
|
- `npm run lint` - ESLint 검사
|
||||||
- `npm run format` - Prettier 포맷팅
|
- `npm run format` - Prettier 포맷팅
|
||||||
- `npx prisma studio` - Prisma Studio 실행
|
|
||||||
|
|
||||||
## 🔧 개발 가이드
|
## 🔧 개발 가이드
|
||||||
|
|
||||||
|
|
@ -160,9 +149,9 @@ npm run test:watch
|
||||||
|
|
||||||
### 데이터베이스 스키마 변경
|
### 데이터베이스 스키마 변경
|
||||||
|
|
||||||
1. `prisma/schema.prisma` 수정
|
1. SQL 마이그레이션 파일 작성 (`db/` 디렉토리)
|
||||||
2. `npx prisma generate` 실행
|
2. PostgreSQL에서 직접 실행
|
||||||
3. `npx prisma migrate dev` 실행
|
3. 필요 시 TypeScript 타입 정의 업데이트 (`src/types/`)
|
||||||
|
|
||||||
## 📋 마이그레이션 체크리스트
|
## 📋 마이그레이션 체크리스트
|
||||||
|
|
||||||
|
|
@ -170,7 +159,7 @@ npm run test:watch
|
||||||
|
|
||||||
- [x] Node.js + TypeScript 프로젝트 설정
|
- [x] Node.js + TypeScript 프로젝트 설정
|
||||||
- [x] 기존 데이터베이스 스키마 분석
|
- [x] 기존 데이터베이스 스키마 분석
|
||||||
- [x] Prisma 스키마 설계 및 마이그레이션
|
- [x] PostgreSQL Raw Query 시스템 구축
|
||||||
- [x] 기본 인증 시스템 구현
|
- [x] 기본 인증 시스템 구현
|
||||||
- [x] 에러 처리 및 로깅 설정
|
- [x] 에러 처리 및 로깅 설정
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
const { Client } = require("pg");
|
|
||||||
require("dotenv/config");
|
|
||||||
|
|
||||||
async function checkActualPassword() {
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log("✅ 데이터베이스 연결 성공");
|
|
||||||
|
|
||||||
// 실제 저장된 비밀번호 확인 (암호화된 상태)
|
|
||||||
const passwordResult = await client.query(`
|
|
||||||
SELECT user_id, user_name, user_password, status
|
|
||||||
FROM user_info
|
|
||||||
WHERE user_id = 'kkh'
|
|
||||||
`);
|
|
||||||
console.log("🔐 사용자 비밀번호 정보:", passwordResult.rows);
|
|
||||||
|
|
||||||
// 다른 사용자도 확인
|
|
||||||
const otherUsersResult = await client.query(`
|
|
||||||
SELECT user_id, user_name, user_password, status
|
|
||||||
FROM user_info
|
|
||||||
WHERE user_password IS NOT NULL
|
|
||||||
AND user_password != ''
|
|
||||||
LIMIT 3
|
|
||||||
`);
|
|
||||||
console.log("👥 다른 사용자 비밀번호 정보:", otherUsersResult.rows);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkActualPassword();
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
const { Client } = require("pg");
|
|
||||||
require("dotenv/config");
|
|
||||||
|
|
||||||
async function checkPasswordField() {
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log("✅ 데이터베이스 연결 성공");
|
|
||||||
|
|
||||||
// user_info 테이블의 컬럼 정보 확인
|
|
||||||
const columnsResult = await client.query(`
|
|
||||||
SELECT column_name, data_type, is_nullable
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'user_info'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
`);
|
|
||||||
console.log("📋 user_info 테이블 컬럼:", columnsResult.rows);
|
|
||||||
|
|
||||||
// 비밀번호 관련 컬럼 확인
|
|
||||||
const passwordResult = await client.query(`
|
|
||||||
SELECT user_id, user_name, user_password, password, status
|
|
||||||
FROM user_info
|
|
||||||
WHERE user_id = 'kkh'
|
|
||||||
`);
|
|
||||||
console.log("🔐 사용자 비밀번호 정보:", passwordResult.rows);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkPasswordField();
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
const { PrismaClient } = require("@prisma/client");
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
async function cleanScreenTables() {
|
|
||||||
try {
|
|
||||||
console.log("🧹 기존 화면관리 테이블들을 정리합니다...");
|
|
||||||
|
|
||||||
// 기존 테이블들을 순서대로 삭제 (외래키 제약조건 때문에 순서 중요)
|
|
||||||
await prisma.$executeRaw`DROP VIEW IF EXISTS v_screen_definitions_with_auth CASCADE`;
|
|
||||||
console.log("✅ 뷰 삭제 완료");
|
|
||||||
|
|
||||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_menu_assignments CASCADE`;
|
|
||||||
console.log("✅ screen_menu_assignments 테이블 삭제 완료");
|
|
||||||
|
|
||||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_widgets CASCADE`;
|
|
||||||
console.log("✅ screen_widgets 테이블 삭제 완료");
|
|
||||||
|
|
||||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_layouts CASCADE`;
|
|
||||||
console.log("✅ screen_layouts 테이블 삭제 완료");
|
|
||||||
|
|
||||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_templates CASCADE`;
|
|
||||||
console.log("✅ screen_templates 테이블 삭제 완료");
|
|
||||||
|
|
||||||
await prisma.$executeRaw`DROP TABLE IF EXISTS screen_definitions CASCADE`;
|
|
||||||
console.log("✅ screen_definitions 테이블 삭제 완료");
|
|
||||||
|
|
||||||
console.log("🎉 모든 화면관리 테이블 정리 완료!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 테이블 정리 중 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanScreenTables();
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
const { Client } = require("pg");
|
|
||||||
require("dotenv/config");
|
|
||||||
|
|
||||||
async function createTestUser() {
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log("✅ 데이터베이스 연결 성공");
|
|
||||||
|
|
||||||
// 테스트용 사용자 생성 (MD5 해시: admin123)
|
|
||||||
const testUser = {
|
|
||||||
user_id: "admin",
|
|
||||||
user_name: "테스트 관리자",
|
|
||||||
user_password: "f21b1ce8b08dc955bd4afff71b3db1fc", // admin123의 MD5 해시
|
|
||||||
status: "active",
|
|
||||||
company_code: "ILSHIN",
|
|
||||||
data_type: "PLM",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 기존 사용자 확인
|
|
||||||
const existingUser = await client.query(
|
|
||||||
"SELECT user_id FROM user_info WHERE user_id = $1",
|
|
||||||
[testUser.user_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingUser.rows.length > 0) {
|
|
||||||
console.log("⚠️ 테스트 사용자가 이미 존재합니다:", testUser.user_id);
|
|
||||||
|
|
||||||
// 기존 사용자 정보 업데이트
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE user_info
|
|
||||||
SET user_name = $1, user_password = $2, status = $3
|
|
||||||
WHERE user_id = $4
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
testUser.user_name,
|
|
||||||
testUser.user_password,
|
|
||||||
testUser.status,
|
|
||||||
testUser.user_id,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ 테스트 사용자 정보 업데이트 완료");
|
|
||||||
} else {
|
|
||||||
// 새 사용자 생성
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
INSERT INTO user_info (user_id, user_name, user_password, status, company_code, data_type)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
testUser.user_id,
|
|
||||||
testUser.user_name,
|
|
||||||
testUser.user_password,
|
|
||||||
testUser.status,
|
|
||||||
testUser.company_code,
|
|
||||||
testUser.data_type,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ 테스트 사용자 생성 완료");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 생성된 사용자 확인
|
|
||||||
const createdUser = await client.query(
|
|
||||||
"SELECT user_id, user_name, status FROM user_info WHERE user_id = $1",
|
|
||||||
[testUser.user_id]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("👤 생성된 사용자:", createdUser.rows[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTestUser();
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -11,23 +11,18 @@
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"lint": "eslint src/ --ext .ts",
|
"lint": "eslint src/ --ext .ts",
|
||||||
"lint:fix": "eslint src/ --ext .ts --fix",
|
"lint:fix": "eslint src/ --ext .ts --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/"
|
||||||
"prisma:generate": "prisma generate",
|
|
||||||
"prisma:migrate": "prisma migrate dev",
|
|
||||||
"prisma:studio": "prisma studio",
|
|
||||||
"prisma:seed": "prisma db seed"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"plm",
|
"plm",
|
||||||
"nodejs",
|
"nodejs",
|
||||||
"typescript",
|
"typescript",
|
||||||
"express",
|
"express",
|
||||||
"prisma"
|
"postgresql"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
|
||||||
"@types/mssql": "^9.1.8",
|
"@types/mssql": "^9.1.8",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|
@ -37,13 +32,15 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.7.5",
|
||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.15.0",
|
"mysql2": "^3.15.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
|
|
@ -55,25 +52,27 @@
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/imap": "^0.8.42",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^1.4.13",
|
"@types/multer": "^1.4.13",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.20",
|
||||||
"@types/oracledb": "^6.9.1",
|
"@types/oracledb": "^6.9.1",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/sanitize-html": "^2.9.5",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.3",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"prisma": "^6.16.2",
|
"supertest": "^6.3.4",
|
||||||
"supertest": "^6.3.3",
|
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (i.e. Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,30 +0,0 @@
|
||||||
const { Client } = require("pg");
|
|
||||||
|
|
||||||
async function createTestUser() {
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
console.log("✅ 데이터베이스 연결 성공");
|
|
||||||
|
|
||||||
// 테스트용 사용자 생성
|
|
||||||
await client.query(`
|
|
||||||
INSERT INTO user_info (user_id, user_name, user_password, status, company_code, data_type)
|
|
||||||
VALUES ('admin', '테스트 관리자', 'f21b1ce8b08dc955bd4afff71b3db1fc', 'active', 'ILSHIN', 'PLM')
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
|
||||||
user_name = EXCLUDED.user_name,
|
|
||||||
user_password = EXCLUDED.user_password,
|
|
||||||
status = EXCLUDED.status
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log("✅ 테스트 사용자 생성/업데이트 완료");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 오류 발생:", error);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createTestUser();
|
|
||||||
|
|
@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
import layoutRoutes from "./routes/layoutRoutes";
|
import layoutRoutes from "./routes/layoutRoutes";
|
||||||
|
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||||
|
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||||
|
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||||
|
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
|
|
@ -43,6 +47,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes";
|
||||||
import externalCallRoutes from "./routes/externalCallRoutes";
|
import externalCallRoutes from "./routes/externalCallRoutes";
|
||||||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||||
|
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -156,6 +161,10 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||||
app.use("/api/layouts", layoutRoutes);
|
app.use("/api/layouts", layoutRoutes);
|
||||||
|
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||||
|
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||||
|
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||||
|
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
|
|
@ -171,6 +180,7 @@ app.use("/api/entity-reference", entityReferenceRoutes);
|
||||||
app.use("/api/external-calls", externalCallRoutes);
|
app.use("/api/external-calls", externalCallRoutes);
|
||||||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||||
|
app.use("/api/dashboards", dashboardRoutes);
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import config from "./environment";
|
|
||||||
|
|
||||||
// Prisma 클라이언트 생성 함수
|
|
||||||
function createPrismaClient() {
|
|
||||||
return new PrismaClient({
|
|
||||||
datasources: {
|
|
||||||
db: {
|
|
||||||
url: config.databaseUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
log: config.debug ? ["query", "info", "warn", "error"] : ["error"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 인스턴스 생성
|
|
||||||
const prisma = createPrismaClient();
|
|
||||||
|
|
||||||
// 데이터베이스 연결 테스트
|
|
||||||
async function testConnection() {
|
|
||||||
try {
|
|
||||||
await prisma.$connect();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 데이터베이스 연결 실패:", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 애플리케이션 종료 시 연결 해제
|
|
||||||
process.on("beforeExit", async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 초기 연결 테스트 (개발 환경에서만)
|
|
||||||
if (config.nodeEnv === "development") {
|
|
||||||
testConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 내보내기
|
|
||||||
export = prisma;
|
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
||||||
|
import { DashboardService } from '../services/DashboardService';
|
||||||
|
import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery } from '../types/dashboard';
|
||||||
|
import { PostgreSQLService } from '../database/PostgreSQLService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 컨트롤러
|
||||||
|
* - REST API 엔드포인트 처리
|
||||||
|
* - 요청 검증 및 응답 포맷팅
|
||||||
|
*/
|
||||||
|
export class DashboardController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 생성
|
||||||
|
* POST /api/dashboards
|
||||||
|
*/
|
||||||
|
async createDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '인증이 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, elements, isPublic = false, tags, category }: CreateDashboardRequest = req.body;
|
||||||
|
|
||||||
|
// 유효성 검증
|
||||||
|
if (!title || title.trim().length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 제목이 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!elements || !Array.isArray(elements)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 요소 데이터가 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제목 길이 체크
|
||||||
|
if (title.length > 200) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '제목은 200자를 초과할 수 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설명 길이 체크
|
||||||
|
if (description && description.length > 1000) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '설명은 1000자를 초과할 수 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardData: CreateDashboardRequest = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim(),
|
||||||
|
isPublic,
|
||||||
|
elements,
|
||||||
|
tags,
|
||||||
|
category
|
||||||
|
};
|
||||||
|
|
||||||
|
// console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length });
|
||||||
|
|
||||||
|
const savedDashboard = await DashboardService.createDashboard(dashboardData, userId);
|
||||||
|
|
||||||
|
// console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title });
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: savedDashboard,
|
||||||
|
message: '대시보드가 성공적으로 생성되었습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
// console.error('Dashboard creation error:', {
|
||||||
|
// message: error?.message,
|
||||||
|
// stack: error?.stack,
|
||||||
|
// error
|
||||||
|
// });
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error?.message || '대시보드 생성 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? error?.message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 목록 조회
|
||||||
|
* GET /api/dashboards
|
||||||
|
*/
|
||||||
|
async getDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
const query: DashboardListQuery = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개
|
||||||
|
search: req.query.search as string,
|
||||||
|
category: req.query.category as string,
|
||||||
|
isPublic: req.query.isPublic === 'true' ? true : req.query.isPublic === 'false' ? false : undefined,
|
||||||
|
createdBy: req.query.createdBy as string
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 번호 유효성 검증
|
||||||
|
if (query.page! < 1) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '페이지 번호는 1 이상이어야 합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DashboardService.getDashboards(query, userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.dashboards,
|
||||||
|
pagination: result.pagination
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Dashboard list error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 상세 조회
|
||||||
|
* GET /api/dashboards/:id
|
||||||
|
*/
|
||||||
|
async getDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 ID가 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = await DashboardService.getDashboardById(id, userId);
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드를 찾을 수 없거나 접근 권한이 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만)
|
||||||
|
if (userId && dashboard.createdBy !== userId) {
|
||||||
|
await DashboardService.incrementViewCount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: dashboard
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Dashboard get error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 조회 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 수정
|
||||||
|
* PUT /api/dashboards/:id
|
||||||
|
*/
|
||||||
|
async updateDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '인증이 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 ID가 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: UpdateDashboardRequest = req.body;
|
||||||
|
|
||||||
|
// 유효성 검증
|
||||||
|
if (updateData.title !== undefined) {
|
||||||
|
if (typeof updateData.title !== 'string' || updateData.title.trim().length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '올바른 제목을 입력해주세요.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (updateData.title.length > 200) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '제목은 200자를 초과할 수 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateData.title = updateData.title.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateData.description !== undefined && updateData.description && updateData.description.length > 1000) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '설명은 1000자를 초과할 수 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDashboard = await DashboardService.updateDashboard(id, updateData, userId);
|
||||||
|
|
||||||
|
if (!updatedDashboard) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드를 찾을 수 없거나 수정 권한이 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: updatedDashboard,
|
||||||
|
message: '대시보드가 성공적으로 수정되었습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Dashboard update error:', error);
|
||||||
|
|
||||||
|
if ((error as Error).message.includes('권한이 없습니다')) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: (error as Error).message
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 수정 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 삭제
|
||||||
|
* DELETE /api/dashboards/:id
|
||||||
|
*/
|
||||||
|
async deleteDashboard(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '인증이 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 ID가 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await DashboardService.deleteDashboard(id, userId);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드를 찾을 수 없거나 삭제 권한이 없습니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: '대시보드가 성공적으로 삭제되었습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Dashboard delete error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '대시보드 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내 대시보드 목록 조회
|
||||||
|
* GET /api/dashboards/my
|
||||||
|
*/
|
||||||
|
async getMyDashboards(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '인증이 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: DashboardListQuery = {
|
||||||
|
page: parseInt(req.query.page as string) || 1,
|
||||||
|
limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
|
||||||
|
search: req.query.search as string,
|
||||||
|
category: req.query.category as string,
|
||||||
|
createdBy: userId // 본인이 만든 대시보드만
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await DashboardService.getDashboards(query, userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.dashboards,
|
||||||
|
pagination: result.pagination
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('My dashboards error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '내 대시보드 목록 조회 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 실행
|
||||||
|
* POST /api/dashboards/execute-query
|
||||||
|
*/
|
||||||
|
async executeQuery(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 개발용으로 인증 체크 제거
|
||||||
|
// const userId = req.user?.userId;
|
||||||
|
// if (!userId) {
|
||||||
|
// res.status(401).json({
|
||||||
|
// success: false,
|
||||||
|
// message: '인증이 필요합니다.'
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { query } = req.body;
|
||||||
|
|
||||||
|
// 유효성 검증
|
||||||
|
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '쿼리가 필요합니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL 인젝션 방지를 위한 기본적인 검증
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
if (!trimmedQuery.startsWith('select')) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'SELECT 쿼리만 허용됩니다.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 실행
|
||||||
|
const result = await PostgreSQLService.query(query.trim());
|
||||||
|
|
||||||
|
// 결과 변환
|
||||||
|
const columns = result.fields?.map(field => field.name) || [];
|
||||||
|
const rows = result.rows || [];
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowCount: rows.length
|
||||||
|
},
|
||||||
|
message: '쿼리가 성공적으로 실행되었습니다.'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('Query execution error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '쿼리 실행 중 오류가 발생했습니다.',
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : '쿼리 실행 오류'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,6 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class ButtonActionStandardController {
|
export class ButtonActionStandardController {
|
||||||
// 버튼 액션 목록 조회
|
// 버튼 액션 목록 조회
|
||||||
|
|
@ -10,33 +8,36 @@ export class ButtonActionStandardController {
|
||||||
try {
|
try {
|
||||||
const { active, category, search } = req.query;
|
const { active, category, search } = req.query;
|
||||||
|
|
||||||
const where: any = {};
|
const whereConditions: string[] = [];
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
where.is_active = active as string;
|
whereConditions.push(`is_active = $${paramIndex}`);
|
||||||
|
queryParams.push(active as string);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
where.category = category as string;
|
whereConditions.push(`category = $${paramIndex}`);
|
||||||
|
queryParams.push(category as string);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
whereConditions.push(`(action_name ILIKE $${paramIndex} OR action_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`);
|
||||||
{ action_name: { contains: search as string, mode: "insensitive" } },
|
queryParams.push(`%${search}%`);
|
||||||
{
|
paramIndex++;
|
||||||
action_name_eng: {
|
|
||||||
contains: search as string,
|
|
||||||
mode: "insensitive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ description: { contains: search as string, mode: "insensitive" } },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonActions = await prisma.button_action_standards.findMany({
|
const whereClause = whereConditions.length > 0
|
||||||
where,
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
orderBy: [{ sort_order: "asc" }, { action_type: "asc" }],
|
: "";
|
||||||
});
|
|
||||||
|
const buttonActions = await query<any>(
|
||||||
|
`SELECT * FROM button_action_standards ${whereClause} ORDER BY sort_order ASC, action_type ASC`,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -58,9 +59,10 @@ export class ButtonActionStandardController {
|
||||||
try {
|
try {
|
||||||
const { actionType } = req.params;
|
const { actionType } = req.params;
|
||||||
|
|
||||||
const buttonAction = await prisma.button_action_standards.findUnique({
|
const buttonAction = await queryOne<any>(
|
||||||
where: { action_type: actionType },
|
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||||
});
|
[actionType]
|
||||||
|
);
|
||||||
|
|
||||||
if (!buttonAction) {
|
if (!buttonAction) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
@ -115,9 +117,10 @@ export class ButtonActionStandardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
const existingAction = await prisma.button_action_standards.findUnique({
|
const existingAction = await queryOne<any>(
|
||||||
where: { action_type },
|
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||||
});
|
[action_type]
|
||||||
|
);
|
||||||
|
|
||||||
if (existingAction) {
|
if (existingAction) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
|
|
@ -126,28 +129,25 @@ export class ButtonActionStandardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newButtonAction = await prisma.button_action_standards.create({
|
const [newButtonAction] = await query<any>(
|
||||||
data: {
|
`INSERT INTO button_action_standards (
|
||||||
action_type,
|
action_type, action_name, action_name_eng, description, category,
|
||||||
action_name,
|
default_text, default_text_eng, default_icon, default_color, default_variant,
|
||||||
action_name_eng,
|
confirmation_required, confirmation_message, validation_rules, action_config,
|
||||||
description,
|
sort_order, is_active, created_by, updated_by, created_date, updated_date
|
||||||
category,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW())
|
||||||
default_text,
|
RETURNING *`,
|
||||||
default_text_eng,
|
[
|
||||||
default_icon,
|
action_type, action_name, action_name_eng, description, category,
|
||||||
default_color,
|
default_text, default_text_eng, default_icon, default_color, default_variant,
|
||||||
default_variant,
|
confirmation_required, confirmation_message,
|
||||||
confirmation_required,
|
validation_rules ? JSON.stringify(validation_rules) : null,
|
||||||
confirmation_message,
|
action_config ? JSON.stringify(action_config) : null,
|
||||||
validation_rules,
|
sort_order, is_active,
|
||||||
action_config,
|
req.user?.userId || "system",
|
||||||
sort_order,
|
req.user?.userId || "system"
|
||||||
is_active,
|
]
|
||||||
created_by: req.user?.userId || "system",
|
);
|
||||||
updated_by: req.user?.userId || "system",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -187,9 +187,10 @@ export class ButtonActionStandardController {
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// 존재 여부 확인
|
// 존재 여부 확인
|
||||||
const existingAction = await prisma.button_action_standards.findUnique({
|
const existingAction = await queryOne<any>(
|
||||||
where: { action_type: actionType },
|
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||||
});
|
[actionType]
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingAction) {
|
if (!existingAction) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
@ -198,28 +199,101 @@ export class ButtonActionStandardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedButtonAction = await prisma.button_action_standards.update({
|
const updateFields: string[] = [];
|
||||||
where: { action_type: actionType },
|
const updateParams: any[] = [];
|
||||||
data: {
|
let paramIndex = 1;
|
||||||
action_name,
|
|
||||||
action_name_eng,
|
if (action_name !== undefined) {
|
||||||
description,
|
updateFields.push(`action_name = $${paramIndex}`);
|
||||||
category,
|
updateParams.push(action_name);
|
||||||
default_text,
|
paramIndex++;
|
||||||
default_text_eng,
|
}
|
||||||
default_icon,
|
if (action_name_eng !== undefined) {
|
||||||
default_color,
|
updateFields.push(`action_name_eng = $${paramIndex}`);
|
||||||
default_variant,
|
updateParams.push(action_name_eng);
|
||||||
confirmation_required,
|
paramIndex++;
|
||||||
confirmation_message,
|
}
|
||||||
validation_rules,
|
if (description !== undefined) {
|
||||||
action_config,
|
updateFields.push(`description = $${paramIndex}`);
|
||||||
sort_order,
|
updateParams.push(description);
|
||||||
is_active,
|
paramIndex++;
|
||||||
updated_by: req.user?.userId || "system",
|
}
|
||||||
updated_date: new Date(),
|
if (category !== undefined) {
|
||||||
},
|
updateFields.push(`category = $${paramIndex}`);
|
||||||
});
|
updateParams.push(category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_text !== undefined) {
|
||||||
|
updateFields.push(`default_text = $${paramIndex}`);
|
||||||
|
updateParams.push(default_text);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_text_eng !== undefined) {
|
||||||
|
updateFields.push(`default_text_eng = $${paramIndex}`);
|
||||||
|
updateParams.push(default_text_eng);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_icon !== undefined) {
|
||||||
|
updateFields.push(`default_icon = $${paramIndex}`);
|
||||||
|
updateParams.push(default_icon);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_color !== undefined) {
|
||||||
|
updateFields.push(`default_color = $${paramIndex}`);
|
||||||
|
updateParams.push(default_color);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_variant !== undefined) {
|
||||||
|
updateFields.push(`default_variant = $${paramIndex}`);
|
||||||
|
updateParams.push(default_variant);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (confirmation_required !== undefined) {
|
||||||
|
updateFields.push(`confirmation_required = $${paramIndex}`);
|
||||||
|
updateParams.push(confirmation_required);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (confirmation_message !== undefined) {
|
||||||
|
updateFields.push(`confirmation_message = $${paramIndex}`);
|
||||||
|
updateParams.push(confirmation_message);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (validation_rules !== undefined) {
|
||||||
|
updateFields.push(`validation_rules = $${paramIndex}`);
|
||||||
|
updateParams.push(validation_rules ? JSON.stringify(validation_rules) : null);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (action_config !== undefined) {
|
||||||
|
updateFields.push(`action_config = $${paramIndex}`);
|
||||||
|
updateParams.push(action_config ? JSON.stringify(action_config) : null);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (sort_order !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex}`);
|
||||||
|
updateParams.push(sort_order);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex}`);
|
||||||
|
updateParams.push(is_active);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
|
updateParams.push(req.user?.userId || "system");
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
updateFields.push(`updated_date = $${paramIndex}`);
|
||||||
|
updateParams.push(new Date());
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
updateParams.push(actionType);
|
||||||
|
|
||||||
|
const [updatedButtonAction] = await query<any>(
|
||||||
|
`UPDATE button_action_standards SET ${updateFields.join(", ")}
|
||||||
|
WHERE action_type = $${paramIndex} RETURNING *`,
|
||||||
|
updateParams
|
||||||
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -242,9 +316,10 @@ export class ButtonActionStandardController {
|
||||||
const { actionType } = req.params;
|
const { actionType } = req.params;
|
||||||
|
|
||||||
// 존재 여부 확인
|
// 존재 여부 확인
|
||||||
const existingAction = await prisma.button_action_standards.findUnique({
|
const existingAction = await queryOne<any>(
|
||||||
where: { action_type: actionType },
|
"SELECT * FROM button_action_standards WHERE action_type = $1 LIMIT 1",
|
||||||
});
|
[actionType]
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingAction) {
|
if (!existingAction) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
@ -253,9 +328,10 @@ export class ButtonActionStandardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.button_action_standards.delete({
|
await query<any>(
|
||||||
where: { action_type: actionType },
|
"DELETE FROM button_action_standards WHERE action_type = $1",
|
||||||
});
|
[actionType]
|
||||||
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -287,18 +363,16 @@ export class ButtonActionStandardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랜잭션으로 일괄 업데이트
|
// 트랜잭션으로 일괄 업데이트
|
||||||
await prisma.$transaction(
|
await transaction(async (client) => {
|
||||||
buttonActions.map((item) =>
|
for (const item of buttonActions) {
|
||||||
prisma.button_action_standards.update({
|
await client.query(
|
||||||
where: { action_type: item.action_type },
|
`UPDATE button_action_standards
|
||||||
data: {
|
SET sort_order = $1, updated_by = $2, updated_date = NOW()
|
||||||
sort_order: item.sort_order,
|
WHERE action_type = $3`,
|
||||||
updated_by: req.user?.userId || "system",
|
[item.sort_order, req.user?.userId || "system", item.action_type]
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -317,19 +391,17 @@ export class ButtonActionStandardController {
|
||||||
// 버튼 액션 카테고리 목록 조회
|
// 버튼 액션 카테고리 목록 조회
|
||||||
static async getButtonActionCategories(req: Request, res: Response) {
|
static async getButtonActionCategories(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const categories = await prisma.button_action_standards.groupBy({
|
const categories = await query<{ category: string; count: string }>(
|
||||||
by: ["category"],
|
`SELECT category, COUNT(*) as count
|
||||||
where: {
|
FROM button_action_standards
|
||||||
is_active: "Y",
|
WHERE is_active = $1
|
||||||
},
|
GROUP BY category`,
|
||||||
_count: {
|
["Y"]
|
||||||
category: true,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoryList = categories.map((item) => ({
|
const categoryList = categories.map((item) => ({
|
||||||
category: item.category,
|
category: item.category,
|
||||||
count: item._count.category,
|
count: parseInt(item.count),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,26 @@ export class CommonCodeController {
|
||||||
|
|
||||||
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||||
const transformedData = result.data.map((code: any) => ({
|
const transformedData = result.data.map((code: any) => ({
|
||||||
|
// 새로운 필드명 (카멜케이스)
|
||||||
codeValue: code.code_value,
|
codeValue: code.code_value,
|
||||||
codeName: code.code_name,
|
codeName: code.code_name,
|
||||||
|
codeNameEng: code.code_name_eng,
|
||||||
description: code.description,
|
description: code.description,
|
||||||
sortOrder: code.sort_order,
|
sortOrder: code.sort_order,
|
||||||
isActive: code.is_active === "Y",
|
isActive: code.is_active,
|
||||||
useYn: code.is_active,
|
useYn: code.is_active,
|
||||||
|
|
||||||
|
// 기존 필드명도 유지 (하위 호환성)
|
||||||
|
code_category: code.code_category,
|
||||||
|
code_value: code.code_value,
|
||||||
|
code_name: code.code_name,
|
||||||
|
code_name_eng: code.code_name_eng,
|
||||||
|
sort_order: code.sort_order,
|
||||||
|
is_active: code.is_active,
|
||||||
|
created_date: code.created_date,
|
||||||
|
created_by: code.created_by,
|
||||||
|
updated_date: code.updated_date,
|
||||||
|
updated_by: code.updated_by,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
@ -119,10 +133,10 @@ export class CommonCodeController {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("카테고리 생성 실패:", error);
|
logger.error("카테고리 생성 실패:", error);
|
||||||
|
|
||||||
// Prisma 에러 처리
|
// PostgreSQL 에러 처리
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
((error as any)?.code === "23505") || // PostgreSQL unique_violation
|
||||||
error.message.includes("Unique constraint")
|
(error instanceof Error && error.message.includes("Unique constraint"))
|
||||||
) {
|
) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
||||||
const isDuplicateError =
|
const isDuplicateError =
|
||||||
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
|
(error && typeof error === "object" && (error as any).code === "23505") || // PostgreSQL unique_violation
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
(error.message.includes("unique constraint") ||
|
(error.message.includes("unique constraint") ||
|
||||||
error.message.includes("Unique constraint") ||
|
error.message.includes("Unique constraint") ||
|
||||||
|
|
@ -236,7 +236,7 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
|
||||||
const isDuplicateError =
|
const isDuplicateError =
|
||||||
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
|
(error && typeof error === "object" && (error as any).code === "23505") || // PostgreSQL unique_violation
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
(error.message.includes("unique constraint") ||
|
(error.message.includes("unique constraint") ||
|
||||||
error.message.includes("Unique constraint") ||
|
error.message.includes("Unique constraint") ||
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import prisma from "../config/database";
|
import { query } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,10 +32,20 @@ export async function executeDataAction(
|
||||||
|
|
||||||
if (connection && connection.id !== 0) {
|
if (connection && connection.id !== 0) {
|
||||||
// 외부 데이터베이스 연결
|
// 외부 데이터베이스 연결
|
||||||
result = await executeExternalDatabaseAction(tableName, data, actionType, connection);
|
result = await executeExternalDatabaseAction(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
actionType,
|
||||||
|
connection
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 메인 데이터베이스 (현재 시스템)
|
// 메인 데이터베이스 (현재 시스템)
|
||||||
result = await executeMainDatabaseAction(tableName, data, actionType, companyCode);
|
result = await executeMainDatabaseAction(
|
||||||
|
tableName,
|
||||||
|
data,
|
||||||
|
actionType,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
|
logger.info(`데이터 액션 실행 완료: ${actionType} on ${tableName}`, result);
|
||||||
|
|
@ -45,7 +55,6 @@ export async function executeDataAction(
|
||||||
message: `데이터 액션 실행 완료: ${actionType}`,
|
message: `데이터 액션 실행 완료: ${actionType}`,
|
||||||
data: result,
|
data: result,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("데이터 액션 실행 실패:", error);
|
logger.error("데이터 액션 실행 실패:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|
@ -73,13 +82,13 @@ async function executeMainDatabaseAction(
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (actionType.toLowerCase()) {
|
switch (actionType.toLowerCase()) {
|
||||||
case 'insert':
|
case "insert":
|
||||||
return await executeInsert(tableName, dataWithCompany);
|
return await executeInsert(tableName, dataWithCompany);
|
||||||
case 'update':
|
case "update":
|
||||||
return await executeUpdate(tableName, dataWithCompany);
|
return await executeUpdate(tableName, dataWithCompany);
|
||||||
case 'upsert':
|
case "upsert":
|
||||||
return await executeUpsert(tableName, dataWithCompany);
|
return await executeUpsert(tableName, dataWithCompany);
|
||||||
case 'delete':
|
case "delete":
|
||||||
return await executeDelete(tableName, dataWithCompany);
|
return await executeDelete(tableName, dataWithCompany);
|
||||||
default:
|
default:
|
||||||
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
||||||
|
|
@ -100,25 +109,37 @@ async function executeExternalDatabaseAction(
|
||||||
connection: any
|
connection: any
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
logger.info(`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`);
|
logger.info(
|
||||||
|
`외부 DB 액션 실행: ${connection.name} (${connection.host}:${connection.port})`
|
||||||
|
);
|
||||||
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
|
logger.info(`테이블: ${tableName}, 액션: ${actionType}`, data);
|
||||||
|
|
||||||
// 🔥 실제 외부 DB 연결 및 실행 로직 구현
|
// 🔥 실제 외부 DB 연결 및 실행 로직 구현
|
||||||
const { MultiConnectionQueryService } = await import('../services/multiConnectionQueryService');
|
const { MultiConnectionQueryService } = await import(
|
||||||
|
"../services/multiConnectionQueryService"
|
||||||
|
);
|
||||||
const queryService = new MultiConnectionQueryService();
|
const queryService = new MultiConnectionQueryService();
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
switch (actionType.toLowerCase()) {
|
switch (actionType.toLowerCase()) {
|
||||||
case 'insert':
|
case "insert":
|
||||||
result = await queryService.insertDataToConnection(connection.id, tableName, data);
|
result = await queryService.insertDataToConnection(
|
||||||
|
connection.id,
|
||||||
|
tableName,
|
||||||
|
data
|
||||||
|
);
|
||||||
logger.info(`외부 DB INSERT 성공:`, result);
|
logger.info(`외부 DB INSERT 성공:`, result);
|
||||||
break;
|
break;
|
||||||
case 'update':
|
case "update":
|
||||||
// TODO: UPDATE 로직 구현 (조건 필요)
|
// TODO: UPDATE 로직 구현 (조건 필요)
|
||||||
throw new Error('UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
throw new Error(
|
||||||
case 'delete':
|
"UPDATE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다."
|
||||||
|
);
|
||||||
|
case "delete":
|
||||||
// TODO: DELETE 로직 구현 (조건 필요)
|
// TODO: DELETE 로직 구현 (조건 필요)
|
||||||
throw new Error('DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다.');
|
throw new Error(
|
||||||
|
"DELETE 액션은 아직 지원되지 않습니다. 조건 설정이 필요합니다."
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
throw new Error(`지원하지 않는 액션 타입: ${actionType}`);
|
||||||
}
|
}
|
||||||
|
|
@ -139,25 +160,28 @@ async function executeExternalDatabaseAction(
|
||||||
/**
|
/**
|
||||||
* INSERT 실행
|
* INSERT 실행
|
||||||
*/
|
*/
|
||||||
async function executeInsert(tableName: string, data: Record<string, any>): Promise<any> {
|
async function executeInsert(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// 동적 테이블 접근을 위한 raw query 사용
|
// 동적 테이블 접근을 위한 raw query 사용
|
||||||
const columns = Object.keys(data).join(', ');
|
const columns = Object.keys(data).join(", ");
|
||||||
const values = Object.values(data);
|
const values = Object.values(data);
|
||||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||||
|
|
||||||
const query = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
|
const insertQuery = `INSERT INTO ${tableName} (${columns}) VALUES (${placeholders}) RETURNING *`;
|
||||||
|
|
||||||
logger.info(`INSERT 쿼리 실행:`, { query, values });
|
logger.info(`INSERT 쿼리 실행:`, { query: insertQuery, values });
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(query, ...values);
|
const result = await query<any>(insertQuery, values);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'insert',
|
action: "insert",
|
||||||
tableName,
|
tableName,
|
||||||
data: result,
|
data: result,
|
||||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
affectedRows: result.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`INSERT 실행 오류:`, error);
|
logger.error(`INSERT 실행 오류:`, error);
|
||||||
|
|
@ -168,32 +192,79 @@ async function executeInsert(tableName: string, data: Record<string, any>): Prom
|
||||||
/**
|
/**
|
||||||
* UPDATE 실행
|
* UPDATE 실행
|
||||||
*/
|
*/
|
||||||
async function executeUpdate(tableName: string, data: Record<string, any>): Promise<any> {
|
async function executeUpdate(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// ID 또는 기본키를 기준으로 업데이트
|
logger.info(`UPDATE 액션 시작:`, { tableName, receivedData: data });
|
||||||
const { id, ...updateData } = data;
|
|
||||||
|
|
||||||
if (!id) {
|
// 1. 테이블의 실제 기본키 조회
|
||||||
throw new Error('UPDATE를 위한 ID가 필요합니다');
|
const primaryKeyQuery = `
|
||||||
|
SELECT a.attname as column_name
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||||
|
`;
|
||||||
|
|
||||||
|
const pkResult = await query<{ column_name: string }>(primaryKeyQuery, [
|
||||||
|
tableName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!pkResult || pkResult.length === 0) {
|
||||||
|
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const primaryKeyColumn = pkResult[0].column_name;
|
||||||
|
logger.info(`테이블 ${tableName}의 기본키:`, primaryKeyColumn);
|
||||||
|
|
||||||
|
// 2. 기본키 값 추출
|
||||||
|
const primaryKeyValue = data[primaryKeyColumn];
|
||||||
|
|
||||||
|
if (!primaryKeyValue && primaryKeyValue !== 0) {
|
||||||
|
logger.error(`UPDATE 실패: 기본키 값이 없음`, {
|
||||||
|
primaryKeyColumn,
|
||||||
|
receivedData: data,
|
||||||
|
availableKeys: Object.keys(data),
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`UPDATE를 위한 기본키 값이 필요합니다 (${primaryKeyColumn})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 업데이트할 데이터에서 기본키 제외
|
||||||
|
const updateData = { ...data };
|
||||||
|
delete updateData[primaryKeyColumn];
|
||||||
|
|
||||||
|
logger.info(`UPDATE 데이터 준비:`, {
|
||||||
|
primaryKeyColumn,
|
||||||
|
primaryKeyValue,
|
||||||
|
updateFields: Object.keys(updateData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 동적 UPDATE 쿼리 생성
|
||||||
const setClause = Object.keys(updateData)
|
const setClause = Object.keys(updateData)
|
||||||
.map((key, index) => `${key} = $${index + 1}`)
|
.map((key, index) => `${key} = $${index + 1}`)
|
||||||
.join(', ');
|
.join(", ");
|
||||||
|
|
||||||
const values = Object.values(updateData);
|
const values = Object.values(updateData);
|
||||||
const query = `UPDATE ${tableName} SET ${setClause} WHERE id = $${values.length + 1} RETURNING *`;
|
const updateQuery = `UPDATE ${tableName} SET ${setClause} WHERE ${primaryKeyColumn} = $${values.length + 1} RETURNING *`;
|
||||||
|
|
||||||
logger.info(`UPDATE 쿼리 실행:`, { query, values: [...values, id] });
|
logger.info(`UPDATE 쿼리 실행:`, {
|
||||||
|
query: updateQuery,
|
||||||
|
values: [...values, primaryKeyValue],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(query, ...values, id);
|
const result = await query<any>(updateQuery, [...values, primaryKeyValue]);
|
||||||
|
|
||||||
|
logger.info(`UPDATE 성공:`, { affectedRows: result.length });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'update',
|
action: "update",
|
||||||
tableName,
|
tableName,
|
||||||
data: result,
|
data: result,
|
||||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
affectedRows: result.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`UPDATE 실행 오류:`, error);
|
logger.error(`UPDATE 실행 오류:`, error);
|
||||||
|
|
@ -204,7 +275,10 @@ async function executeUpdate(tableName: string, data: Record<string, any>): Prom
|
||||||
/**
|
/**
|
||||||
* UPSERT 실행
|
* UPSERT 실행
|
||||||
*/
|
*/
|
||||||
async function executeUpsert(tableName: string, data: Record<string, any>): Promise<any> {
|
async function executeUpsert(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
|
// 먼저 INSERT를 시도하고, 실패하면 UPDATE
|
||||||
try {
|
try {
|
||||||
|
|
@ -223,26 +297,29 @@ async function executeUpsert(tableName: string, data: Record<string, any>): Prom
|
||||||
/**
|
/**
|
||||||
* DELETE 실행
|
* DELETE 실행
|
||||||
*/
|
*/
|
||||||
async function executeDelete(tableName: string, data: Record<string, any>): Promise<any> {
|
async function executeDelete(
|
||||||
|
tableName: string,
|
||||||
|
data: Record<string, any>
|
||||||
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { id } = data;
|
const { id } = data;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error('DELETE를 위한 ID가 필요합니다');
|
throw new Error("DELETE를 위한 ID가 필요합니다");
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
|
const deleteQuery = `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`;
|
||||||
|
|
||||||
logger.info(`DELETE 쿼리 실행:`, { query, values: [id] });
|
logger.info(`DELETE 쿼리 실행:`, { query: deleteQuery, values: [id] });
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(query, id);
|
const result = await query<any>(deleteQuery, [id]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
action: 'delete',
|
action: "delete",
|
||||||
tableName,
|
tableName,
|
||||||
data: result,
|
data: result,
|
||||||
affectedRows: Array.isArray(result) ? result.length : 1,
|
affectedRows: result.length,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`DELETE 실행 오류:`, error);
|
logger.error(`DELETE 실행 오류:`, error);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export interface EntityReferenceOption {
|
export interface EntityReferenceOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -39,12 +37,12 @@ export class EntityReferenceController {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
// 컬럼 정보 조회
|
||||||
const columnInfo = await prisma.column_labels.findFirst({
|
const columnInfo = await queryOne<any>(
|
||||||
where: {
|
`SELECT * FROM column_labels
|
||||||
table_name: tableName,
|
WHERE table_name = $1 AND column_name = $2
|
||||||
column_name: columnName,
|
LIMIT 1`,
|
||||||
},
|
[tableName, columnName]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!columnInfo) {
|
if (!columnInfo) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
@ -76,7 +74,7 @@ export class EntityReferenceController {
|
||||||
|
|
||||||
// 참조 테이블이 실제로 존재하는지 확인
|
// 참조 테이블이 실제로 존재하는지 확인
|
||||||
try {
|
try {
|
||||||
await prisma.$queryRawUnsafe(`SELECT 1 FROM ${referenceTable} LIMIT 1`);
|
await query<any>(`SELECT 1 FROM ${referenceTable} LIMIT 1`);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Entity 참조 설정: ${tableName}.${columnName} -> ${referenceTable}.${referenceColumn} (display: ${displayColumn})`
|
`Entity 참조 설정: ${tableName}.${columnName} -> ${referenceTable}.${referenceColumn} (display: ${displayColumn})`
|
||||||
);
|
);
|
||||||
|
|
@ -92,26 +90,26 @@ export class EntityReferenceController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 쿼리로 참조 데이터 조회
|
// 동적 쿼리로 참조 데이터 조회
|
||||||
let query = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||||
const queryParams: any[] = [];
|
const queryParams: any[] = [];
|
||||||
|
|
||||||
// 검색 조건 추가
|
// 검색 조건 추가
|
||||||
if (search) {
|
if (search) {
|
||||||
query += ` WHERE ${displayColumn} ILIKE $1`;
|
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||||
queryParams.push(`%${search}%`);
|
queryParams.push(`%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||||
queryParams.push(Number(limit));
|
queryParams.push(Number(limit));
|
||||||
|
|
||||||
logger.info(`실행할 쿼리: ${query}`, {
|
logger.info(`실행할 쿼리: ${sqlQuery}`, {
|
||||||
queryParams,
|
queryParams,
|
||||||
referenceTable,
|
referenceTable,
|
||||||
referenceColumn,
|
referenceColumn,
|
||||||
displayColumn,
|
displayColumn,
|
||||||
});
|
});
|
||||||
|
|
||||||
const referenceData = await prisma.$queryRawUnsafe(query, ...queryParams);
|
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||||
|
|
||||||
// 옵션 형태로 변환
|
// 옵션 형태로 변환
|
||||||
const options: EntityReferenceOption[] = (referenceData as any[]).map(
|
const options: EntityReferenceOption[] = (referenceData as any[]).map(
|
||||||
|
|
@ -158,29 +156,22 @@ export class EntityReferenceController {
|
||||||
});
|
});
|
||||||
|
|
||||||
// code_info 테이블에서 코드 데이터 조회
|
// code_info 테이블에서 코드 데이터 조회
|
||||||
let whereCondition: any = {
|
const queryParams: any[] = [codeCategory, 'Y'];
|
||||||
code_category: codeCategory,
|
let sqlQuery = `
|
||||||
is_active: "Y",
|
SELECT code_value, code_name
|
||||||
};
|
FROM code_info
|
||||||
|
WHERE code_category = $1 AND is_active = $2
|
||||||
|
`;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereCondition.code_name = {
|
sqlQuery += ` AND code_name ILIKE $3`;
|
||||||
contains: String(search),
|
queryParams.push(`%${search}%`);
|
||||||
mode: "insensitive",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeData = await prisma.code_info.findMany({
|
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||||
where: whereCondition,
|
queryParams.push(Number(limit));
|
||||||
select: {
|
|
||||||
code_value: true,
|
const codeData = await query<any>(sqlQuery, queryParams);
|
||||||
code_name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
code_name: "asc",
|
|
||||||
},
|
|
||||||
take: Number(limit),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 옵션 형태로 변환
|
// 옵션 형태로 변환
|
||||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@ import { AuthenticatedRequest } from "../types/auth";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { generateUUID } from "../utils/generateId";
|
import { generateUUID } from "../utils/generateId";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
// 임시 토큰 저장소 (메모리 기반, 실제 운영에서는 Redis 사용 권장)
|
||||||
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
const tempTokens = new Map<string, { objid: string; expires: number }>();
|
||||||
|
|
@ -68,16 +66,19 @@ const storage = multer.diskStorage({
|
||||||
console.log("📁 파일명 처리:", {
|
console.log("📁 파일명 처리:", {
|
||||||
originalname: file.originalname,
|
originalname: file.originalname,
|
||||||
encoding: file.encoding,
|
encoding: file.encoding,
|
||||||
mimetype: file.mimetype
|
mimetype: file.mimetype,
|
||||||
});
|
});
|
||||||
|
|
||||||
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
// UTF-8 인코딩 문제 해결: Buffer를 통한 올바른 디코딩
|
||||||
let decodedName;
|
let decodedName;
|
||||||
try {
|
try {
|
||||||
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
// 파일명이 깨진 경우 Buffer를 통해 올바르게 디코딩
|
||||||
const buffer = Buffer.from(file.originalname, 'latin1');
|
const buffer = Buffer.from(file.originalname, "latin1");
|
||||||
decodedName = buffer.toString('utf8');
|
decodedName = buffer.toString("utf8");
|
||||||
console.log("📁 파일명 디코딩:", { original: file.originalname, decoded: decodedName });
|
console.log("📁 파일명 디코딩:", {
|
||||||
|
original: file.originalname,
|
||||||
|
decoded: decodedName,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 디코딩 실패 시 원본 사용
|
// 디코딩 실패 시 원본 사용
|
||||||
decodedName = file.originalname;
|
decodedName = file.originalname;
|
||||||
|
|
@ -96,7 +97,7 @@ const storage = multer.diskStorage({
|
||||||
console.log("📁 파일명 변환:", {
|
console.log("📁 파일명 변환:", {
|
||||||
original: file.originalname,
|
original: file.originalname,
|
||||||
sanitized: sanitizedName,
|
sanitized: sanitizedName,
|
||||||
saved: savedFileName
|
saved: savedFileName,
|
||||||
});
|
});
|
||||||
|
|
||||||
cb(null, savedFileName);
|
cb(null, savedFileName);
|
||||||
|
|
@ -246,12 +247,18 @@ export const uploadFiles = async (
|
||||||
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
// 파일명 디코딩 (파일 저장 시와 동일한 로직)
|
||||||
let decodedOriginalName;
|
let decodedOriginalName;
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(file.originalname, 'latin1');
|
const buffer = Buffer.from(file.originalname, "latin1");
|
||||||
decodedOriginalName = buffer.toString('utf8');
|
decodedOriginalName = buffer.toString("utf8");
|
||||||
console.log("💾 DB 저장용 파일명 디코딩:", { original: file.originalname, decoded: decodedOriginalName });
|
console.log("💾 DB 저장용 파일명 디코딩:", {
|
||||||
|
original: file.originalname,
|
||||||
|
decoded: decodedOriginalName,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
decodedOriginalName = file.originalname;
|
decodedOriginalName = file.originalname;
|
||||||
console.log("💾 DB 저장용 파일명 디코딩 실패, 원본 사용:", file.originalname);
|
console.log(
|
||||||
|
"💾 DB 저장용 파일명 디코딩 실패, 원본 사용:",
|
||||||
|
file.originalname
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 확장자 추출
|
// 파일 확장자 추출
|
||||||
|
|
@ -283,27 +290,34 @@ export const uploadFiles = async (
|
||||||
const fullFilePath = `/uploads${relativePath}`;
|
const fullFilePath = `/uploads${relativePath}`;
|
||||||
|
|
||||||
// attach_file_info 테이블에 저장
|
// attach_file_info 테이블에 저장
|
||||||
const fileRecord = await prisma.attach_file_info.create({
|
const objidValue = parseInt(
|
||||||
data: {
|
|
||||||
objid: parseInt(
|
|
||||||
generateUUID().replace(/-/g, "").substring(0, 15),
|
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||||
16
|
16
|
||||||
),
|
);
|
||||||
target_objid: finalTargetObjid,
|
|
||||||
saved_file_name: file.filename,
|
const [fileRecord] = await query<any>(
|
||||||
real_file_name: decodedOriginalName,
|
`INSERT INTO attach_file_info (
|
||||||
doc_type: docType,
|
objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name,
|
||||||
doc_type_name: docTypeName,
|
file_size, file_ext, file_path, company_code, writer, regdate, status, parent_target_objid
|
||||||
file_size: file.size,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
file_ext: fileExt,
|
RETURNING *`,
|
||||||
file_path: fullFilePath, // 회사별 디렉토리 포함된 경로
|
[
|
||||||
company_code: companyCode, // 회사코드 추가
|
objidValue,
|
||||||
writer: writer,
|
finalTargetObjid,
|
||||||
regdate: new Date(),
|
file.filename,
|
||||||
status: "ACTIVE",
|
decodedOriginalName,
|
||||||
parent_target_objid: parentTargetObjid,
|
docType,
|
||||||
},
|
docTypeName,
|
||||||
});
|
file.size,
|
||||||
|
fileExt,
|
||||||
|
fullFilePath,
|
||||||
|
companyCode,
|
||||||
|
writer,
|
||||||
|
new Date(),
|
||||||
|
"ACTIVE",
|
||||||
|
parentTargetObjid,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
savedFiles.push({
|
savedFiles.push({
|
||||||
objid: fileRecord.objid.toString(),
|
objid: fileRecord.objid.toString(),
|
||||||
|
|
@ -350,14 +364,10 @@ export const deleteFile = async (
|
||||||
const { writer = "system" } = req.body;
|
const { writer = "system" } = req.body;
|
||||||
|
|
||||||
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
||||||
const deletedFile = await prisma.attach_file_info.update({
|
await query<any>(
|
||||||
where: {
|
"UPDATE attach_file_info SET status = $1 WHERE objid = $2",
|
||||||
objid: parseInt(objid),
|
["DELETED", parseInt(objid)]
|
||||||
},
|
);
|
||||||
data: {
|
|
||||||
status: "DELETED",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -387,17 +397,12 @@ export const getLinkedFiles = async (
|
||||||
const baseTargetObjid = `${tableName}:${recordId}`;
|
const baseTargetObjid = `${tableName}:${recordId}`;
|
||||||
|
|
||||||
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
||||||
const files = await prisma.attach_file_info.findMany({
|
const files = await query<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info
|
||||||
target_objid: {
|
WHERE target_objid LIKE $1 AND status = $2
|
||||||
startsWith: baseTargetObjid, // tableName:recordId로 시작하는 모든 파일
|
ORDER BY regdate DESC`,
|
||||||
},
|
[`${baseTargetObjid}%`, "ACTIVE"]
|
||||||
status: "ACTIVE",
|
);
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
regdate: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileList = files.map((file: any) => ({
|
const fileList = files.map((file: any) => ({
|
||||||
objid: file.objid.toString(),
|
objid: file.objid.toString(),
|
||||||
|
|
@ -441,24 +446,28 @@ export const getFileList = async (
|
||||||
try {
|
try {
|
||||||
const { targetObjid, docType, companyCode } = req.query;
|
const { targetObjid, docType, companyCode } = req.query;
|
||||||
|
|
||||||
const where: any = {
|
const whereConditions: string[] = ["status = $1"];
|
||||||
status: "ACTIVE",
|
const queryParams: any[] = ["ACTIVE"];
|
||||||
};
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (targetObjid) {
|
if (targetObjid) {
|
||||||
where.target_objid = targetObjid as string;
|
whereConditions.push(`target_objid = $${paramIndex}`);
|
||||||
|
queryParams.push(targetObjid as string);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (docType) {
|
if (docType) {
|
||||||
where.doc_type = docType as string;
|
whereConditions.push(`doc_type = $${paramIndex}`);
|
||||||
|
queryParams.push(docType as string);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await prisma.attach_file_info.findMany({
|
const files = await query<any>(
|
||||||
where,
|
`SELECT * FROM attach_file_info
|
||||||
orderBy: {
|
WHERE ${whereConditions.join(" AND ")}
|
||||||
regdate: "desc",
|
ORDER BY regdate DESC`,
|
||||||
},
|
queryParams
|
||||||
});
|
);
|
||||||
|
|
||||||
const fileList = files.map((file: any) => ({
|
const fileList = files.map((file: any) => ({
|
||||||
objid: file.objid.toString(),
|
objid: file.objid.toString(),
|
||||||
|
|
@ -498,7 +507,8 @@ export const getComponentFiles = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { screenId, componentId, tableName, recordId, columnName } = req.query;
|
const { screenId, componentId, tableName, recordId, columnName } =
|
||||||
|
req.query;
|
||||||
|
|
||||||
console.log("📂 [getComponentFiles] API 호출:", {
|
console.log("📂 [getComponentFiles] API 호출:", {
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -506,7 +516,7 @@ export const getComponentFiles = async (
|
||||||
tableName,
|
tableName,
|
||||||
recordId,
|
recordId,
|
||||||
columnName,
|
columnName,
|
||||||
user: req.user?.userId
|
user: req.user?.userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!screenId || !componentId) {
|
if (!screenId || !componentId) {
|
||||||
|
|
@ -519,51 +529,50 @@ export const getComponentFiles = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
// 1. 템플릿 파일 조회 (화면 설계 시 업로드한 파일들)
|
||||||
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || 'field_1'}`;
|
const templateTargetObjid = `screen_files:${screenId}:${componentId}:${columnName || "field_1"}`;
|
||||||
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", { templateTargetObjid });
|
console.log("🔍 [getComponentFiles] 템플릿 파일 조회:", {
|
||||||
|
templateTargetObjid,
|
||||||
|
});
|
||||||
|
|
||||||
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
// 모든 파일 조회해서 실제 저장된 target_objid 패턴 확인
|
||||||
const allFiles = await prisma.attach_file_info.findMany({
|
const allFiles = await query<any>(
|
||||||
where: {
|
`SELECT target_objid, real_file_name, regdate
|
||||||
status: "ACTIVE",
|
FROM attach_file_info
|
||||||
},
|
WHERE status = $1
|
||||||
select: {
|
ORDER BY regdate DESC
|
||||||
target_objid: true,
|
LIMIT 10`,
|
||||||
real_file_name: true,
|
["ACTIVE"]
|
||||||
regdate: true,
|
);
|
||||||
},
|
console.log(
|
||||||
orderBy: {
|
"🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:",
|
||||||
regdate: "desc",
|
allFiles.map((f) => ({
|
||||||
},
|
target_objid: f.target_objid,
|
||||||
take: 10,
|
name: f.real_file_name,
|
||||||
});
|
}))
|
||||||
console.log("🗂️ [getComponentFiles] 최근 저장된 파일들의 target_objid:", allFiles.map(f => ({ target_objid: f.target_objid, name: f.real_file_name })));
|
);
|
||||||
|
|
||||||
const templateFiles = await prisma.attach_file_info.findMany({
|
const templateFiles = await query<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info
|
||||||
target_objid: templateTargetObjid,
|
WHERE target_objid = $1 AND status = $2
|
||||||
status: "ACTIVE",
|
ORDER BY regdate DESC`,
|
||||||
},
|
[templateTargetObjid, "ACTIVE"]
|
||||||
orderBy: {
|
);
|
||||||
regdate: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("📁 [getComponentFiles] 템플릿 파일 결과:", templateFiles.length);
|
console.log(
|
||||||
|
"📁 [getComponentFiles] 템플릿 파일 결과:",
|
||||||
|
templateFiles.length
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
// 2. 데이터 파일 조회 (실제 레코드와 연결된 파일들)
|
||||||
let dataFiles: any[] = [];
|
let dataFiles: any[] = [];
|
||||||
if (tableName && recordId && columnName) {
|
if (tableName && recordId && columnName) {
|
||||||
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
const dataTargetObjid = `${tableName}:${recordId}:${columnName}`;
|
||||||
dataFiles = await prisma.attach_file_info.findMany({
|
dataFiles = await query<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info
|
||||||
target_objid: dataTargetObjid,
|
WHERE target_objid = $1 AND status = $2
|
||||||
status: "ACTIVE",
|
ORDER BY regdate DESC`,
|
||||||
},
|
[dataTargetObjid, "ACTIVE"]
|
||||||
orderBy: {
|
);
|
||||||
regdate: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 정보 포맷팅 함수
|
// 파일 정보 포맷팅 함수
|
||||||
|
|
@ -584,11 +593,16 @@ export const getComponentFiles = async (
|
||||||
isTemplate, // 템플릿 파일 여부 표시
|
isTemplate, // 템플릿 파일 여부 표시
|
||||||
});
|
});
|
||||||
|
|
||||||
const formattedTemplateFiles = templateFiles.map(file => formatFileInfo(file, true));
|
const formattedTemplateFiles = templateFiles.map((file) =>
|
||||||
const formattedDataFiles = dataFiles.map(file => formatFileInfo(file, false));
|
formatFileInfo(file, true)
|
||||||
|
);
|
||||||
|
const formattedDataFiles = dataFiles.map((file) =>
|
||||||
|
formatFileInfo(file, false)
|
||||||
|
);
|
||||||
|
|
||||||
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
// 3. 전체 파일 목록 (데이터 파일 우선, 없으면 템플릿 파일 표시)
|
||||||
const totalFiles = formattedDataFiles.length > 0
|
const totalFiles =
|
||||||
|
formattedDataFiles.length > 0
|
||||||
? formattedDataFiles
|
? formattedDataFiles
|
||||||
: formattedTemplateFiles;
|
: formattedTemplateFiles;
|
||||||
|
|
||||||
|
|
@ -602,7 +616,8 @@ export const getComponentFiles = async (
|
||||||
dataCount: formattedDataFiles.length,
|
dataCount: formattedDataFiles.length,
|
||||||
totalCount: totalFiles.length,
|
totalCount: totalFiles.length,
|
||||||
templateTargetObjid,
|
templateTargetObjid,
|
||||||
dataTargetObjid: tableName && recordId && columnName
|
dataTargetObjid:
|
||||||
|
tableName && recordId && columnName
|
||||||
? `${tableName}:${recordId}:${columnName}`
|
? `${tableName}:${recordId}:${columnName}`
|
||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
|
|
@ -628,11 +643,10 @@ export const previewFile = async (
|
||||||
const { objid } = req.params;
|
const { objid } = req.params;
|
||||||
const { serverFilename } = req.query;
|
const { serverFilename } = req.query;
|
||||||
|
|
||||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
const fileRecord = await queryOne<any>(
|
||||||
where: {
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||||
objid: parseInt(objid),
|
[parseInt(objid)]
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -673,7 +687,7 @@ export const previewFile = async (
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
companyUploadDir: companyUploadDir,
|
companyUploadDir: companyUploadDir,
|
||||||
finalFilePath: filePath,
|
finalFilePath: filePath,
|
||||||
fileExists: fs.existsSync(filePath)
|
fileExists: fs.existsSync(filePath),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
|
@ -748,11 +762,10 @@ export const downloadFile = async (
|
||||||
try {
|
try {
|
||||||
const { objid } = req.params;
|
const { objid } = req.params;
|
||||||
|
|
||||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
const fileRecord = await queryOne<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info WHERE objid = $1`,
|
||||||
objid: parseInt(objid),
|
[parseInt(objid)]
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
if (!fileRecord || fileRecord.status !== "ACTIVE") {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -794,7 +807,7 @@ export const downloadFile = async (
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
companyUploadDir: companyUploadDir,
|
companyUploadDir: companyUploadDir,
|
||||||
finalFilePath: filePath,
|
finalFilePath: filePath,
|
||||||
fileExists: fs.existsSync(filePath)
|
fileExists: fs.existsSync(filePath),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
|
@ -829,7 +842,10 @@ export const downloadFile = async (
|
||||||
/**
|
/**
|
||||||
* Google Docs Viewer용 임시 공개 토큰 생성
|
* Google Docs Viewer용 임시 공개 토큰 생성
|
||||||
*/
|
*/
|
||||||
export const generateTempToken = async (req: AuthenticatedRequest, res: Response) => {
|
export const generateTempToken = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { objid } = req.params;
|
const { objid } = req.params;
|
||||||
|
|
||||||
|
|
@ -842,9 +858,10 @@ export const generateTempToken = async (req: AuthenticatedRequest, res: Response
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 존재 확인
|
// 파일 존재 확인
|
||||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
const fileRecord = await queryOne<any>(
|
||||||
where: { objid: objid },
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||||
});
|
[objid]
|
||||||
|
);
|
||||||
|
|
||||||
if (!fileRecord) {
|
if (!fileRecord) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -924,9 +941,10 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 파일 정보 조회
|
// 파일 정보 조회
|
||||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
const fileRecord = await queryOne<any>(
|
||||||
where: { objid: tokenData.objid },
|
"SELECT * FROM attach_file_info WHERE objid = $1 LIMIT 1",
|
||||||
});
|
[tokenData.objid]
|
||||||
|
);
|
||||||
|
|
||||||
if (!fileRecord) {
|
if (!fileRecord) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -947,7 +965,10 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
||||||
if (filePathParts.length >= 6) {
|
if (filePathParts.length >= 6) {
|
||||||
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
dateFolder = `${filePathParts[3]}/${filePathParts[4]}/${filePathParts[5]}`;
|
||||||
}
|
}
|
||||||
const companyUploadDir = getCompanyUploadDir(companyCode, dateFolder || undefined);
|
const companyUploadDir = getCompanyUploadDir(
|
||||||
|
companyCode,
|
||||||
|
dateFolder || undefined
|
||||||
|
);
|
||||||
const filePath = path.join(companyUploadDir, fileName);
|
const filePath = path.join(companyUploadDir, fileName);
|
||||||
|
|
||||||
// 파일 존재 확인
|
// 파일 존재 확인
|
||||||
|
|
@ -966,11 +987,14 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
||||||
const mimeTypes: { [key: string]: string } = {
|
const mimeTypes: { [key: string]: string } = {
|
||||||
".pdf": "application/pdf",
|
".pdf": "application/pdf",
|
||||||
".doc": "application/msword",
|
".doc": "application/msword",
|
||||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
".docx":
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
".xls": "application/vnd.ms-excel",
|
".xls": "application/vnd.ms-excel",
|
||||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
".xlsx":
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
".ppt": "application/vnd.ms-powerpoint",
|
".ppt": "application/vnd.ms-powerpoint",
|
||||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
".pptx":
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
".jpg": "image/jpeg",
|
".jpg": "image/jpeg",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
|
|
@ -984,7 +1008,10 @@ export const getFileByToken = async (req: Request, res: Response) => {
|
||||||
|
|
||||||
// 파일 헤더 설정
|
// 파일 헤더 설정
|
||||||
res.setHeader("Content-Type", contentType);
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader("Content-Disposition", `inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`);
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`inline; filename="${encodeURIComponent(fileRecord.real_file_name!)}"`
|
||||||
|
);
|
||||||
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
res.setHeader("Cache-Control", "public, max-age=300"); // 5분 캐시
|
||||||
|
|
||||||
// 파일 스트림 전송
|
// 파일 스트림 전송
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mailAccountFileService } from '../services/mailAccountFileService';
|
||||||
|
|
||||||
|
export class MailAccountFileController {
|
||||||
|
async getAllAccounts(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const accounts = await mailAccountFileService.getAllAccounts();
|
||||||
|
|
||||||
|
// 비밀번호는 반환하지 않음
|
||||||
|
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: safeAccounts,
|
||||||
|
total: safeAccounts.length,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 조회 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountById(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const account = await mailAccountFileService.getAccountById(id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호는 마스킹 처리
|
||||||
|
const { smtpPassword, ...safeAccount } = account;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...safeAccount,
|
||||||
|
smtpPassword: '••••••••', // 마스킹
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 조회 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure,
|
||||||
|
smtpUsername,
|
||||||
|
smtpPassword,
|
||||||
|
dailyLimit,
|
||||||
|
status,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '필수 필드가 누락되었습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 중복 확인
|
||||||
|
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
|
||||||
|
if (existingAccount) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '이미 등록된 이메일입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await mailAccountFileService.createAccount({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpSecure: smtpSecure || false,
|
||||||
|
smtpUsername,
|
||||||
|
smtpPassword,
|
||||||
|
dailyLimit: dailyLimit || 1000,
|
||||||
|
status: status || 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 비밀번호 제외하고 반환
|
||||||
|
const { smtpPassword: _, ...safeAccount } = account;
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: safeAccount,
|
||||||
|
message: '메일 계정이 생성되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 생성 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const account = await mailAccountFileService.updateAccount(id, updates);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 제외하고 반환
|
||||||
|
const { smtpPassword: _, ...safeAccount } = account;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: safeAccount,
|
||||||
|
message: '계정이 수정되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 수정 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const success = await mailAccountFileService.deleteAccount(id);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: '계정이 삭제되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 삭제 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const account = await mailAccountFileService.getAccountById(id);
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// mailSendSimpleService의 testConnection 사용
|
||||||
|
const { mailSendSimpleService } = require('../services/mailSendSimpleService');
|
||||||
|
const result = await mailSendSimpleService.testConnection(id);
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '연결 테스트 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailAccountFileController = new MailAccountFileController();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* 메일 수신 컨트롤러 (Step 2 - 기본 구현)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { MailReceiveBasicService } from '../services/mailReceiveBasicService';
|
||||||
|
|
||||||
|
export class MailReceiveBasicController {
|
||||||
|
private mailReceiveService: MailReceiveBasicService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.mailReceiveService = new MailReceiveBasicService();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mail/receive/:accountId
|
||||||
|
* 메일 목록 조회
|
||||||
|
*/
|
||||||
|
async getMailList(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
||||||
|
const mails = await this.mailReceiveService.fetchMailList(accountId, limit);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: mails,
|
||||||
|
count: mails.length,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('메일 목록 조회 실패:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '메일 목록 조회 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mail/receive/:accountId/:seqno
|
||||||
|
* 메일 상세 조회
|
||||||
|
*/
|
||||||
|
async getMailDetail(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId, seqno } = req.params;
|
||||||
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
|
|
||||||
|
if (isNaN(seqnoNumber)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 메일 번호입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailDetail = await this.mailReceiveService.getMailDetail(accountId, seqnoNumber);
|
||||||
|
|
||||||
|
if (!mailDetail) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '메일을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: mailDetail,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('메일 상세 조회 실패:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '메일 상세 조회 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mail/receive/:accountId/:seqno/mark-read
|
||||||
|
* 메일을 읽음으로 표시
|
||||||
|
*/
|
||||||
|
async markAsRead(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId, seqno } = req.params;
|
||||||
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
|
|
||||||
|
if (isNaN(seqnoNumber)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 메일 번호입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.mailReceiveService.markAsRead(accountId, seqnoNumber);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('읽음 표시 실패:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '읽음 표시 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/mail/receive/:accountId/:seqno/attachment/:index
|
||||||
|
* 첨부파일 다운로드
|
||||||
|
*/
|
||||||
|
async downloadAttachment(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId, seqno, index } = req.params;
|
||||||
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
|
const indexNumber = parseInt(index, 10);
|
||||||
|
|
||||||
|
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '유효하지 않은 파라미터입니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.mailReceiveService.downloadAttachment(
|
||||||
|
accountId,
|
||||||
|
seqnoNumber,
|
||||||
|
indexNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '첨부파일을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 다운로드
|
||||||
|
res.download(result.filePath, result.filename, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('파일 다운로드 오류:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '파일 다운로드 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return; // void 반환
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('첨부파일 다운로드 실패:', error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : '첨부파일 다운로드 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/mail/receive/:accountId/test-imap
|
||||||
|
* IMAP 연결 테스트
|
||||||
|
*/
|
||||||
|
async testImapConnection(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.params;
|
||||||
|
|
||||||
|
const result = await this.mailReceiveService.testImapConnection(accountId);
|
||||||
|
|
||||||
|
return res.status(result.success ? 200 : 400).json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'IMAP 연결 테스트 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||||
|
|
||||||
|
export class MailSendSimpleController {
|
||||||
|
/**
|
||||||
|
* 메일 발송 (단건 또는 소규모)
|
||||||
|
*/
|
||||||
|
async sendMail(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
|
||||||
|
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
||||||
|
|
||||||
|
// 필수 파라미터 검증
|
||||||
|
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||||
|
console.log('❌ 필수 파라미터 누락');
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '메일 제목이 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
|
||||||
|
if (!templateId && !customHtml) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 또는 메일 내용이 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메일 발송
|
||||||
|
const result = await mailSendSimpleService.sendMail({
|
||||||
|
accountId,
|
||||||
|
templateId,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
variables,
|
||||||
|
customHtml,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: '메일이 발송되었습니다.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: result.error || '메일 발송 실패',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '메일 발송 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP 연결 테스트
|
||||||
|
*/
|
||||||
|
async testConnection(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { accountId } = req.body;
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '계정 ID가 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mailSendSimpleService.testConnection(accountId);
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '연결 테스트 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailSendSimpleController = new MailSendSimpleController();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mailTemplateFileService } from '../services/mailTemplateFileService';
|
||||||
|
|
||||||
|
// 간단한 변수 치환 함수
|
||||||
|
function replaceVariables(text: string, data: Record<string, any>): string {
|
||||||
|
let result = text;
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
|
result = result.replace(regex, String(value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MailTemplateFileController {
|
||||||
|
// 모든 템플릿 조회
|
||||||
|
async getAllTemplates(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { category, search } = req.query;
|
||||||
|
|
||||||
|
let templates;
|
||||||
|
if (search) {
|
||||||
|
templates = await mailTemplateFileService.searchTemplates(search as string);
|
||||||
|
} else if (category) {
|
||||||
|
templates = await mailTemplateFileService.getTemplatesByCategory(category as string);
|
||||||
|
} else {
|
||||||
|
templates = await mailTemplateFileService.getAllTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: templates,
|
||||||
|
total: templates.length,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 조회 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특정 템플릿 조회
|
||||||
|
async getTemplateById(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const template = await mailTemplateFileService.getTemplateById(id);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: template,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 조회 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 생성
|
||||||
|
async createTemplate(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { name, subject, components, queryConfig, recipientConfig, category } = req.body;
|
||||||
|
|
||||||
|
if (!name || !subject || !Array.isArray(components)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 이름, 제목, 컴포넌트가 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = await mailTemplateFileService.createTemplate({
|
||||||
|
name,
|
||||||
|
subject,
|
||||||
|
components,
|
||||||
|
queryConfig,
|
||||||
|
recipientConfig,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: template,
|
||||||
|
message: '템플릿이 생성되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 생성 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 수정
|
||||||
|
async updateTemplate(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
const template = await mailTemplateFileService.updateTemplate(id, updates);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: template,
|
||||||
|
message: '템플릿이 수정되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 수정 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 삭제
|
||||||
|
async deleteTemplate(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const success = await mailTemplateFileService.deleteTemplate(id);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: '템플릿이 삭제되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿 삭제 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 미리보기 (HTML 렌더링)
|
||||||
|
async previewTemplate(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { sampleData } = req.body;
|
||||||
|
|
||||||
|
const template = await mailTemplateFileService.getTemplateById(id);
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 렌더링
|
||||||
|
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
|
||||||
|
let subject = template.subject;
|
||||||
|
|
||||||
|
// 샘플 데이터가 있으면 변수 치환
|
||||||
|
if (sampleData) {
|
||||||
|
html = replaceVariables(html, sampleData);
|
||||||
|
subject = replaceVariables(subject, sampleData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
sampleData,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '미리보기 생성 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 템플릿 + 쿼리 통합 미리보기
|
||||||
|
async previewWithQuery(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { queryId, parameters } = req.body;
|
||||||
|
|
||||||
|
const template = await mailTemplateFileService.getTemplateById(id);
|
||||||
|
if (!template) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '템플릿을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 실행
|
||||||
|
const query = template.queryConfig?.queries.find(q => q.id === queryId);
|
||||||
|
if (!query) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '쿼리를 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL 쿼리 기능은 구현되지 않음
|
||||||
|
return res.status(501).json({
|
||||||
|
success: false,
|
||||||
|
message: 'SQL 쿼리 연동 기능은 현재 지원하지 않습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '쿼리 미리보기 실패',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailTemplateFileController = new MailTemplateFileController();
|
||||||
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from "express";
|
||||||
import { AuthenticatedRequest } from '../middleware/authMiddleware';
|
import { AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { query } from "../database/db";
|
||||||
import logger from '../utils/logger';
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 화면 컴포넌트별 파일 정보 조회 및 복원
|
* 화면 컴포넌트별 파일 정보 조회 및 복원
|
||||||
|
|
@ -20,24 +18,20 @@ export const getScreenComponentFiles = async (
|
||||||
// screen_files: 접두사로 해당 화면의 모든 파일 조회
|
// screen_files: 접두사로 해당 화면의 모든 파일 조회
|
||||||
const targetObjidPattern = `screen_files:${screenId}:%`;
|
const targetObjidPattern = `screen_files:${screenId}:%`;
|
||||||
|
|
||||||
const files = await prisma.attach_file_info.findMany({
|
const files = await query<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info
|
||||||
target_objid: {
|
WHERE target_objid LIKE $1
|
||||||
startsWith: `screen_files:${screenId}:`
|
AND status = 'ACTIVE'
|
||||||
},
|
ORDER BY regdate DESC`,
|
||||||
status: 'ACTIVE'
|
[`screen_files:${screenId}:%`]
|
||||||
},
|
);
|
||||||
orderBy: {
|
|
||||||
regdate: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컴포넌트별로 파일 그룹화
|
// 컴포넌트별로 파일 그룹화
|
||||||
const componentFiles: { [componentId: string]: any[] } = {};
|
const componentFiles: { [componentId: string]: any[] } = {};
|
||||||
|
|
||||||
files.forEach(file => {
|
files.forEach((file) => {
|
||||||
// target_objid 형식: screen_files:screenId:componentId:fieldName
|
// target_objid 형식: screen_files:screenId:componentId:fieldName
|
||||||
const targetParts = file.target_objid?.split(':') || [];
|
const targetParts = file.target_objid?.split(":") || [];
|
||||||
if (targetParts.length >= 3) {
|
if (targetParts.length >= 3) {
|
||||||
const componentId = targetParts[2];
|
const componentId = targetParts[2];
|
||||||
|
|
||||||
|
|
@ -58,26 +52,27 @@ export const getScreenComponentFiles = async (
|
||||||
parentTargetObjid: file.parent_target_objid,
|
parentTargetObjid: file.parent_target_objid,
|
||||||
writer: file.writer,
|
writer: file.writer,
|
||||||
regdate: file.regdate?.toISOString(),
|
regdate: file.regdate?.toISOString(),
|
||||||
status: file.status
|
status: file.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`화면 컴포넌트 파일 조회 완료: ${Object.keys(componentFiles).length}개 컴포넌트, 총 ${files.length}개 파일`);
|
logger.info(
|
||||||
|
`화면 컴포넌트 파일 조회 완료: ${Object.keys(componentFiles).length}개 컴포넌트, 총 ${files.length}개 파일`
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
componentFiles: componentFiles,
|
componentFiles: componentFiles,
|
||||||
totalFiles: files.length,
|
totalFiles: files.length,
|
||||||
componentCount: Object.keys(componentFiles).length
|
componentCount: Object.keys(componentFiles).length,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('화면 컴포넌트 파일 조회 오류:', error);
|
logger.error("화면 컴포넌트 파일 조회 오류:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '화면 컴포넌트 파일 조회 중 오류가 발생했습니다.',
|
message: "화면 컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -92,24 +87,22 @@ export const getComponentFiles = async (
|
||||||
try {
|
try {
|
||||||
const { screenId, componentId } = req.params;
|
const { screenId, componentId } = req.params;
|
||||||
|
|
||||||
logger.info(`컴포넌트 파일 조회: screenId=${screenId}, componentId=${componentId}`);
|
logger.info(
|
||||||
|
`컴포넌트 파일 조회: screenId=${screenId}, componentId=${componentId}`
|
||||||
|
);
|
||||||
|
|
||||||
// target_objid 패턴: screen_files:screenId:componentId:*
|
// target_objid 패턴: screen_files:screenId:componentId:*
|
||||||
const targetObjidPattern = `screen_files:${screenId}:${componentId}:`;
|
const targetObjidPattern = `screen_files:${screenId}:${componentId}:`;
|
||||||
|
|
||||||
const files = await prisma.attach_file_info.findMany({
|
const files = await query<any>(
|
||||||
where: {
|
`SELECT * FROM attach_file_info
|
||||||
target_objid: {
|
WHERE target_objid LIKE $1
|
||||||
startsWith: targetObjidPattern
|
AND status = 'ACTIVE'
|
||||||
},
|
ORDER BY regdate DESC`,
|
||||||
status: 'ACTIVE'
|
[`${targetObjidPattern}%`]
|
||||||
},
|
);
|
||||||
orderBy: {
|
|
||||||
regdate: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileList = files.map(file => ({
|
const fileList = files.map((file) => ({
|
||||||
objid: file.objid.toString(),
|
objid: file.objid.toString(),
|
||||||
savedFileName: file.saved_file_name,
|
savedFileName: file.saved_file_name,
|
||||||
realFileName: file.real_file_name,
|
realFileName: file.real_file_name,
|
||||||
|
|
@ -122,7 +115,7 @@ export const getComponentFiles = async (
|
||||||
parentTargetObjid: file.parent_target_objid,
|
parentTargetObjid: file.parent_target_objid,
|
||||||
writer: file.writer,
|
writer: file.writer,
|
||||||
regdate: file.regdate?.toISOString(),
|
regdate: file.regdate?.toISOString(),
|
||||||
status: file.status
|
status: file.status,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
logger.info(`컴포넌트 파일 조회 완료: ${fileList.length}개 파일`);
|
logger.info(`컴포넌트 파일 조회 완료: ${fileList.length}개 파일`);
|
||||||
|
|
@ -131,15 +124,14 @@ export const getComponentFiles = async (
|
||||||
success: true,
|
success: true,
|
||||||
files: fileList,
|
files: fileList,
|
||||||
componentId: componentId,
|
componentId: componentId,
|
||||||
screenId: screenId
|
screenId: screenId,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('컴포넌트 파일 조회 오류:', error);
|
logger.error("컴포넌트 파일 조회 오류:", error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '컴포넌트 파일 조회 중 오류가 발생했습니다.',
|
message: "컴포넌트 파일 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,51 @@
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class WebTypeStandardController {
|
export class WebTypeStandardController {
|
||||||
// 웹타입 목록 조회
|
// 웹타입 목록 조회
|
||||||
static async getWebTypes(req: Request, res: Response) {
|
static async getWebTypes(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { active, category, search } = req.query;
|
const { active, category, search } = req.query;
|
||||||
|
|
||||||
const where: any = {};
|
// 동적 WHERE 절 생성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
where.is_active = active as string;
|
whereConditions.push(`is_active = $${paramIndex}`);
|
||||||
|
queryParams.push(active);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
where.category = category as string;
|
whereConditions.push(`category = $${paramIndex}`);
|
||||||
|
queryParams.push(category);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search && typeof search === "string") {
|
||||||
where.OR = [
|
whereConditions.push(`(
|
||||||
{ type_name: { contains: search as string, mode: "insensitive" } },
|
type_name ILIKE $${paramIndex} OR
|
||||||
{
|
type_name_eng ILIKE $${paramIndex} OR
|
||||||
type_name_eng: { contains: search as string, mode: "insensitive" },
|
description ILIKE $${paramIndex}
|
||||||
},
|
)`);
|
||||||
{ description: { contains: search as string, mode: "insensitive" } },
|
queryParams.push(`%${search}%`);
|
||||||
];
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const webTypes = await prisma.web_type_standards.findMany({
|
const whereClause =
|
||||||
where,
|
whereConditions.length > 0
|
||||||
orderBy: [{ sort_order: "asc" }, { web_type: "asc" }],
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
});
|
: "";
|
||||||
|
|
||||||
|
const webTypes = await query<any>(
|
||||||
|
`SELECT * FROM web_type_standards
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY sort_order ASC, web_type ASC`,
|
||||||
|
queryParams
|
||||||
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -55,9 +67,10 @@ export class WebTypeStandardController {
|
||||||
try {
|
try {
|
||||||
const { webType } = req.params;
|
const { webType } = req.params;
|
||||||
|
|
||||||
const webTypeData = await prisma.web_type_standards.findUnique({
|
const webTypeData = await queryOne<any>(
|
||||||
where: { web_type: webType },
|
`SELECT * FROM web_type_standards WHERE web_type = $1`,
|
||||||
});
|
[webType]
|
||||||
|
);
|
||||||
|
|
||||||
if (!webTypeData) {
|
if (!webTypeData) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|
@ -109,9 +122,10 @@ export class WebTypeStandardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
const existingWebType = await queryOne<any>(
|
||||||
where: { web_type },
|
`SELECT web_type FROM web_type_standards WHERE web_type = $1`,
|
||||||
});
|
[web_type]
|
||||||
|
);
|
||||||
|
|
||||||
if (existingWebType) {
|
if (existingWebType) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
|
|
@ -120,8 +134,15 @@ export class WebTypeStandardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newWebType = await prisma.web_type_standards.create({
|
const [newWebType] = await query<any>(
|
||||||
data: {
|
`INSERT INTO web_type_standards (
|
||||||
|
web_type, type_name, type_name_eng, description, category,
|
||||||
|
component_name, config_panel, default_config, validation_rules,
|
||||||
|
default_style, input_properties, sort_order, is_active,
|
||||||
|
created_by, created_date, updated_by, updated_date
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, NOW(), $15, NOW())
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
web_type,
|
web_type,
|
||||||
type_name,
|
type_name,
|
||||||
type_name_eng,
|
type_name_eng,
|
||||||
|
|
@ -135,10 +156,10 @@ export class WebTypeStandardController {
|
||||||
input_properties,
|
input_properties,
|
||||||
sort_order,
|
sort_order,
|
||||||
is_active,
|
is_active,
|
||||||
created_by: req.user?.userId || "system",
|
req.user?.userId || "system",
|
||||||
updated_by: req.user?.userId || "system",
|
req.user?.userId || "system",
|
||||||
},
|
]
|
||||||
});
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -174,37 +195,106 @@ export class WebTypeStandardController {
|
||||||
is_active,
|
is_active,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// 존재 여부 확인
|
// 동적 UPDATE 쿼리 생성
|
||||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
const updateFields: string[] = [];
|
||||||
where: { web_type: webType },
|
const updateValues: any[] = [];
|
||||||
});
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (!existingWebType) {
|
if (type_name !== undefined) {
|
||||||
|
updateFields.push(`type_name = $${paramIndex}`);
|
||||||
|
updateValues.push(type_name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (type_name_eng !== undefined) {
|
||||||
|
updateFields.push(`type_name_eng = $${paramIndex}`);
|
||||||
|
updateValues.push(type_name_eng);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex}`);
|
||||||
|
updateValues.push(description);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (category !== undefined) {
|
||||||
|
updateFields.push(`category = $${paramIndex}`);
|
||||||
|
updateValues.push(category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (component_name !== undefined) {
|
||||||
|
updateFields.push(`component_name = $${paramIndex}`);
|
||||||
|
updateValues.push(component_name);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (config_panel !== undefined) {
|
||||||
|
updateFields.push(`config_panel = $${paramIndex}`);
|
||||||
|
updateValues.push(config_panel);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_config !== undefined) {
|
||||||
|
updateFields.push(`default_config = $${paramIndex}`);
|
||||||
|
updateValues.push(default_config);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (validation_rules !== undefined) {
|
||||||
|
updateFields.push(`validation_rules = $${paramIndex}`);
|
||||||
|
updateValues.push(validation_rules);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (default_style !== undefined) {
|
||||||
|
updateFields.push(`default_style = $${paramIndex}`);
|
||||||
|
updateValues.push(default_style);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (input_properties !== undefined) {
|
||||||
|
updateFields.push(`input_properties = $${paramIndex}`);
|
||||||
|
updateValues.push(input_properties);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (sort_order !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex}`);
|
||||||
|
updateValues.push(sort_order);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex}`);
|
||||||
|
updateValues.push(is_active);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// updated_by, updated_date는 항상 추가
|
||||||
|
updateFields.push(`updated_by = $${paramIndex}`);
|
||||||
|
updateValues.push(req.user?.userId || "system");
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
updateFields.push(`updated_date = NOW()`);
|
||||||
|
|
||||||
|
if (updateFields.length === 2) {
|
||||||
|
// updated_by, updated_date만 있는 경우 = 수정할 내용이 없음
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "수정할 내용이 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHERE 조건용 파라미터 추가
|
||||||
|
updateValues.push(webType);
|
||||||
|
|
||||||
|
const result = await query<any>(
|
||||||
|
`UPDATE web_type_standards
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE web_type = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
updateValues
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "해당 웹타입을 찾을 수 없습니다.",
|
message: "해당 웹타입을 찾을 수 없습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedWebType = await prisma.web_type_standards.update({
|
const updatedWebType = result[0];
|
||||||
where: { web_type: webType },
|
|
||||||
data: {
|
|
||||||
type_name,
|
|
||||||
type_name_eng,
|
|
||||||
description,
|
|
||||||
category,
|
|
||||||
component_name,
|
|
||||||
config_panel,
|
|
||||||
default_config,
|
|
||||||
validation_rules,
|
|
||||||
default_style,
|
|
||||||
input_properties,
|
|
||||||
sort_order,
|
|
||||||
is_active,
|
|
||||||
updated_by: req.user?.userId || "system",
|
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -226,22 +316,18 @@ export class WebTypeStandardController {
|
||||||
try {
|
try {
|
||||||
const { webType } = req.params;
|
const { webType } = req.params;
|
||||||
|
|
||||||
// 존재 여부 확인
|
const result = await query<any>(
|
||||||
const existingWebType = await prisma.web_type_standards.findUnique({
|
`DELETE FROM web_type_standards WHERE web_type = $1 RETURNING *`,
|
||||||
where: { web_type: webType },
|
[webType]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!existingWebType) {
|
if (result.length === 0) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "해당 웹타입을 찾을 수 없습니다.",
|
message: "해당 웹타입을 찾을 수 없습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.web_type_standards.delete({
|
|
||||||
where: { web_type: webType },
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "웹타입이 성공적으로 삭제되었습니다.",
|
message: "웹타입이 성공적으로 삭제되었습니다.",
|
||||||
|
|
@ -272,18 +358,16 @@ export class WebTypeStandardController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트랜잭션으로 일괄 업데이트
|
// 트랜잭션으로 일괄 업데이트
|
||||||
await prisma.$transaction(
|
await transaction(async (client) => {
|
||||||
webTypes.map((item) =>
|
for (const item of webTypes) {
|
||||||
prisma.web_type_standards.update({
|
await client.query(
|
||||||
where: { web_type: item.web_type },
|
`UPDATE web_type_standards
|
||||||
data: {
|
SET sort_order = $1, updated_by = $2, updated_date = NOW()
|
||||||
sort_order: item.sort_order,
|
WHERE web_type = $3`,
|
||||||
updated_by: req.user?.userId || "system",
|
[item.sort_order, req.user?.userId || "system", item.web_type]
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -302,19 +386,17 @@ export class WebTypeStandardController {
|
||||||
// 웹타입 카테고리 목록 조회
|
// 웹타입 카테고리 목록 조회
|
||||||
static async getWebTypeCategories(req: Request, res: Response) {
|
static async getWebTypeCategories(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const categories = await prisma.web_type_standards.groupBy({
|
const categories = await query<{ category: string; count: string }>(
|
||||||
by: ["category"],
|
`SELECT category, COUNT(*) as count
|
||||||
where: {
|
FROM web_type_standards
|
||||||
is_active: "Y",
|
WHERE is_active = 'Y'
|
||||||
},
|
GROUP BY category`,
|
||||||
_count: {
|
[]
|
||||||
category: true,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const categoryList = categories.map((item) => ({
|
const categoryList = categories.map((item) => ({
|
||||||
category: item.category,
|
category: item.category,
|
||||||
count: item._count.category,
|
count: parseInt(item.count, 10),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import * as oracledb from 'oracledb';
|
import * as oracledb from "oracledb";
|
||||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
import {
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
DatabaseConnector,
|
||||||
|
ConnectionConfig,
|
||||||
|
QueryResult,
|
||||||
|
} from "../interfaces/DatabaseConnector";
|
||||||
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
|
||||||
export class OracleConnector implements DatabaseConnector {
|
export class OracleConnector implements DatabaseConnector {
|
||||||
private connection: oracledb.Connection | null = null;
|
private connection: oracledb.Connection | null = null;
|
||||||
|
|
@ -23,13 +27,13 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
const connectionConfig: any = {
|
const connectionConfig: any = {
|
||||||
user: this.config.user,
|
user: this.config.user,
|
||||||
password: this.config.password,
|
password: this.config.password,
|
||||||
connectString: connectionString
|
connectString: connectionString,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.connection = await oracledb.getConnection(connectionConfig);
|
this.connection = await oracledb.getConnection(connectionConfig);
|
||||||
console.log('Oracle XE 21c 연결 성공');
|
console.log("Oracle XE 21c 연결 성공");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Oracle XE 21c 연결 실패:', error);
|
console.error("Oracle XE 21c 연결 실패:", error);
|
||||||
throw new Error(`Oracle 연결 실패: ${error.message}`);
|
throw new Error(`Oracle 연결 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -39,7 +43,7 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
|
|
||||||
// Oracle XE 21c는 기본적으로 XE 서비스명을 사용
|
// Oracle XE 21c는 기본적으로 XE 서비스명을 사용
|
||||||
// 다양한 연결 문자열 형식 지원
|
// 다양한 연결 문자열 형식 지원
|
||||||
if (database.includes('/') || database.includes(':')) {
|
if (database.includes("/") || database.includes(":")) {
|
||||||
// 이미 완전한 연결 문자열인 경우
|
// 이미 완전한 연결 문자열인 경우
|
||||||
return database;
|
return database;
|
||||||
}
|
}
|
||||||
|
|
@ -53,9 +57,9 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
try {
|
try {
|
||||||
await this.connection.close();
|
await this.connection.close();
|
||||||
this.connection = null;
|
this.connection = null;
|
||||||
console.log('Oracle 연결 해제됨');
|
console.log("Oracle 연결 해제됨");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Oracle 연결 해제 실패:', error);
|
console.error("Oracle 연결 해제 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,25 +72,25 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
|
|
||||||
// Oracle XE 21c 버전 확인 쿼리
|
// Oracle XE 21c 버전 확인 쿼리
|
||||||
const result = await this.connection!.execute(
|
const result = await this.connection!.execute(
|
||||||
'SELECT BANNER FROM V$VERSION WHERE BANNER LIKE \'Oracle%\''
|
"SELECT BANNER FROM V$VERSION WHERE BANNER LIKE 'Oracle%'"
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Oracle 버전:', result.rows);
|
console.log("Oracle 버전:", result.rows);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '연결 성공',
|
message: "연결 성공",
|
||||||
details: {
|
details: {
|
||||||
server_version: (result.rows as any)?.[0]?.BANNER || 'Unknown'
|
server_version: (result.rows as any)?.[0]?.BANNER || "Unknown",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Oracle 연결 테스트 실패:', error);
|
console.error("Oracle 연결 테스트 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: '연결 실패',
|
message: "연결 실패",
|
||||||
details: {
|
details: {
|
||||||
server_version: error.message
|
server_version: error.message,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,51 +103,82 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// 쿼리 타입 확인
|
||||||
|
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
|
||||||
|
const isCOMMIT = /^\s*COMMIT/i.test(query);
|
||||||
|
const isROLLBACK = /^\s*ROLLBACK/i.test(query);
|
||||||
|
|
||||||
|
// 🔥 COMMIT/ROLLBACK 명령은 직접 실행
|
||||||
|
if (isCOMMIT || isROLLBACK) {
|
||||||
|
if (isCOMMIT) {
|
||||||
|
await (this.connection as any).commit();
|
||||||
|
console.log("✅ Oracle COMMIT 실행됨");
|
||||||
|
} else {
|
||||||
|
await (this.connection as any).rollback();
|
||||||
|
console.log("⚠️ Oracle ROLLBACK 실행됨");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
rowCount: 0,
|
||||||
|
fields: [],
|
||||||
|
affectedRows: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Oracle XE 21c 쿼리 실행 옵션
|
// Oracle XE 21c 쿼리 실행 옵션
|
||||||
const options: any = {
|
const options: any = {
|
||||||
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
||||||
maxRows: 10000, // XE 제한 고려
|
maxRows: 10000, // XE 제한 고려
|
||||||
fetchArraySize: 100
|
fetchArraySize: 100,
|
||||||
|
autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("Oracle 쿼리 실행:", {
|
||||||
|
query: query.substring(0, 100) + "...",
|
||||||
|
isDML,
|
||||||
|
autoCommit: options.autoCommit,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await this.connection!.execute(query, params, options);
|
const result = await this.connection!.execute(query, params, options);
|
||||||
const executionTime = Date.now() - startTime;
|
const executionTime = Date.now() - startTime;
|
||||||
|
|
||||||
console.log('Oracle 쿼리 실행 결과:', {
|
console.log("Oracle 쿼리 실행 결과:", {
|
||||||
query,
|
query,
|
||||||
rowCount: result.rows?.length || 0,
|
rowCount: result.rows?.length || 0,
|
||||||
|
rowsAffected: result.rowsAffected,
|
||||||
metaData: result.metaData?.length || 0,
|
metaData: result.metaData?.length || 0,
|
||||||
executionTime: `${executionTime}ms`,
|
executionTime: `${executionTime}ms`,
|
||||||
actualRows: result.rows,
|
actualRows: result.rows,
|
||||||
metaDataInfo: result.metaData
|
metaDataInfo: result.metaData,
|
||||||
|
autoCommit: options.autoCommit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: result.rows || [],
|
rows: result.rows || [],
|
||||||
rowCount: result.rowsAffected || (result.rows?.length || 0),
|
rowCount: result.rowsAffected || result.rows?.length || 0,
|
||||||
fields: this.extractFieldInfo(result.metaData || [])
|
fields: this.extractFieldInfo(result.metaData || []),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Oracle 쿼리 실행 실패:', error);
|
console.error("Oracle 쿼리 실행 실패:", error);
|
||||||
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
throw new Error(`쿼리 실행 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractFieldInfo(metaData: any[]): any[] {
|
private extractFieldInfo(metaData: any[]): any[] {
|
||||||
return metaData.map(field => ({
|
return metaData.map((field) => ({
|
||||||
name: field.name,
|
name: field.name,
|
||||||
type: this.mapOracleType(field.dbType),
|
type: this.mapOracleType(field.dbType),
|
||||||
length: field.precision || field.byteSize,
|
length: field.precision || field.byteSize,
|
||||||
nullable: field.nullable
|
nullable: field.nullable,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapOracleType(oracleType: any): string {
|
private mapOracleType(oracleType: any): string {
|
||||||
// Oracle XE 21c 타입 매핑 (간단한 방식)
|
// Oracle XE 21c 타입 매핑 (간단한 방식)
|
||||||
if (typeof oracleType === 'string') {
|
if (typeof oracleType === "string") {
|
||||||
return oracleType;
|
return oracleType;
|
||||||
}
|
}
|
||||||
return 'UNKNOWN';
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTables(): Promise<TableInfo[]> {
|
async getTables(): Promise<TableInfo[]> {
|
||||||
|
|
@ -155,22 +190,21 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
ORDER BY table_name
|
ORDER BY table_name
|
||||||
`;
|
`;
|
||||||
|
|
||||||
console.log('Oracle 테이블 조회 시작 - 사용자:', this.config.user);
|
console.log("Oracle 테이블 조회 시작 - 사용자:", this.config.user);
|
||||||
|
|
||||||
const result = await this.executeQuery(query);
|
const result = await this.executeQuery(query);
|
||||||
console.log('사용자 스키마 테이블 조회 결과:', result.rows);
|
console.log("사용자 스키마 테이블 조회 결과:", result.rows);
|
||||||
|
|
||||||
const tables = result.rows.map((row: any) => ({
|
const tables = result.rows.map((row: any) => ({
|
||||||
table_name: row.TABLE_NAME,
|
table_name: row.TABLE_NAME,
|
||||||
columns: [],
|
columns: [],
|
||||||
description: null
|
description: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`);
|
console.log(`총 ${tables.length}개의 사용자 테이블을 찾았습니다.`);
|
||||||
return tables;
|
return tables;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Oracle 테이블 목록 조회 실패:', error);
|
console.error("Oracle 테이블 목록 조회 실패:", error);
|
||||||
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
throw new Error(`테이블 목록 조회 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,19 +231,22 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
const result = await this.executeQuery(query, [tableName]);
|
const result = await this.executeQuery(query, [tableName]);
|
||||||
|
|
||||||
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
|
console.log(`[OracleConnector] 쿼리 결과:`, result.rows);
|
||||||
console.log(`[OracleConnector] 결과 개수:`, result.rows ? result.rows.length : 'null/undefined');
|
console.log(
|
||||||
|
`[OracleConnector] 결과 개수:`,
|
||||||
|
result.rows ? result.rows.length : "null/undefined"
|
||||||
|
);
|
||||||
|
|
||||||
const mappedResult = result.rows.map((row: any) => ({
|
const mappedResult = result.rows.map((row: any) => ({
|
||||||
column_name: row.COLUMN_NAME,
|
column_name: row.COLUMN_NAME,
|
||||||
data_type: this.formatOracleDataType(row),
|
data_type: this.formatOracleDataType(row),
|
||||||
is_nullable: row.NULLABLE === 'Y' ? 'YES' : 'NO',
|
is_nullable: row.NULLABLE === "Y" ? "YES" : "NO",
|
||||||
column_default: row.DATA_DEFAULT
|
column_default: row.DATA_DEFAULT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
|
console.log(`[OracleConnector] 매핑된 결과:`, mappedResult);
|
||||||
return mappedResult;
|
return mappedResult;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[OracleConnector] getColumns 오류:', error);
|
console.error("[OracleConnector] getColumns 오류:", error);
|
||||||
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
|
throw new Error(`테이블 컬럼 조회 실패: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,15 +255,15 @@ export class OracleConnector implements DatabaseConnector {
|
||||||
const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row;
|
const { DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE } = row;
|
||||||
|
|
||||||
switch (DATA_TYPE) {
|
switch (DATA_TYPE) {
|
||||||
case 'NUMBER':
|
case "NUMBER":
|
||||||
if (DATA_PRECISION && DATA_SCALE !== null) {
|
if (DATA_PRECISION && DATA_SCALE !== null) {
|
||||||
return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`;
|
return `NUMBER(${DATA_PRECISION},${DATA_SCALE})`;
|
||||||
} else if (DATA_PRECISION) {
|
} else if (DATA_PRECISION) {
|
||||||
return `NUMBER(${DATA_PRECISION})`;
|
return `NUMBER(${DATA_PRECISION})`;
|
||||||
}
|
}
|
||||||
return 'NUMBER';
|
return "NUMBER";
|
||||||
case 'VARCHAR2':
|
case "VARCHAR2":
|
||||||
case 'CHAR':
|
case "CHAR":
|
||||||
return `${DATA_TYPE}(${DATA_LENGTH})`;
|
return `${DATA_TYPE}(${DATA_LENGTH})`;
|
||||||
default:
|
default:
|
||||||
return DATA_TYPE;
|
return DATA_TYPE;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { Pool, PoolClient, QueryResult } from 'pg';
|
||||||
|
import config from '../config/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL Raw Query 서비스
|
||||||
|
* Prisma 대신 직접 pg 라이브러리를 사용
|
||||||
|
*/
|
||||||
|
export class PostgreSQLService {
|
||||||
|
private static pool: Pool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 연결 풀 초기화
|
||||||
|
*/
|
||||||
|
static initialize() {
|
||||||
|
if (!this.pool) {
|
||||||
|
this.pool = new Pool({
|
||||||
|
connectionString: config.databaseUrl,
|
||||||
|
max: 20, // 최대 연결 수
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 풀 이벤트 리스너
|
||||||
|
this.pool.on('connect', () => {
|
||||||
|
console.log('🔗 PostgreSQL 연결 성공');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on('error', (err) => {
|
||||||
|
console.error('❌ PostgreSQL 연결 오류:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 가져오기
|
||||||
|
*/
|
||||||
|
static getPool(): Pool {
|
||||||
|
if (!this.pool) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
return this.pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 쿼리 실행
|
||||||
|
*/
|
||||||
|
static async query(text: string, params?: any[]): Promise<QueryResult> {
|
||||||
|
const pool = this.getPool();
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
if (config.debug) {
|
||||||
|
console.log('🔍 Query executed:', { text, duration: `${duration}ms`, rows: result.rowCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Query error:', { text, params, error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랜잭션 실행
|
||||||
|
*/
|
||||||
|
static async transaction<T>(callback: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
|
const pool = this.getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const result = await callback(client);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 테스트
|
||||||
|
*/
|
||||||
|
static async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await this.query('SELECT NOW() as current_time');
|
||||||
|
console.log('✅ PostgreSQL 연결 테스트 성공:', result.rows[0]);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ PostgreSQL 연결 테스트 실패:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 종료
|
||||||
|
*/
|
||||||
|
static async close(): Promise<void> {
|
||||||
|
if (this.pool) {
|
||||||
|
await this.pool.end();
|
||||||
|
console.log('🔒 PostgreSQL 연결 풀 종료');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애플리케이션 시작 시 초기화
|
||||||
|
PostgreSQLService.initialize();
|
||||||
|
|
||||||
|
// 프로세스 종료 시 연결 정리
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
await PostgreSQLService.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await PostgreSQLService.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('beforeExit', async () => {
|
||||||
|
await PostgreSQLService.close();
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
import {
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
DatabaseConnector,
|
||||||
|
ConnectionConfig,
|
||||||
|
QueryResult,
|
||||||
|
} from "../interfaces/DatabaseConnector";
|
||||||
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
|
||||||
export interface RestApiConfig {
|
export interface RestApiConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -26,10 +30,10 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
baseURL: config.baseUrl,
|
baseURL: config.baseUrl,
|
||||||
timeout: config.timeout || 30000,
|
timeout: config.timeout || 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'Authorization': `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
'Accept': 'application/json'
|
Accept: "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 요청/응답 인터셉터 설정
|
// 요청/응답 인터셉터 설정
|
||||||
|
|
@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 요청 인터셉터
|
// 요청 인터셉터
|
||||||
this.httpClient.interceptors.request.use(
|
this.httpClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`
|
||||||
|
);
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('[RestApiConnector] 요청 오류:', error);
|
console.error("[RestApiConnector] 요청 오류:", error);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 응답 인터셉터
|
// 응답 인터셉터
|
||||||
this.httpClient.interceptors.response.use(
|
this.httpClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 응답: ${response.status} ${response.statusText}`
|
||||||
|
);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
console.error(
|
||||||
|
"[RestApiConnector] 응답 오류:",
|
||||||
|
error.response?.status,
|
||||||
|
error.response?.statusText
|
||||||
|
);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
async connect(): Promise<void> {
|
async connect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 연결 테스트 - 기본 엔드포인트 호출
|
// 연결 테스트 - 기본 엔드포인트 호출
|
||||||
await this.httpClient.get('/health', { timeout: 5000 });
|
await this.httpClient.get("/health", { timeout: 5000 });
|
||||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
|
console.log(
|
||||||
|
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
|
console.error(
|
||||||
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'REST API 연결이 성공했습니다.',
|
message: "REST API 연결이 성공했습니다.",
|
||||||
details: {
|
details: {
|
||||||
response_time: Date.now()
|
response_time: Date.now(),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "REST API 연결에 실패했습니다.",
|
||||||
details: {
|
details: {
|
||||||
response_time: Date.now()
|
response_time: Date.now(),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
// 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음)
|
||||||
|
async executeQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||||
|
// REST API는 executeRequest를 사용해야 함
|
||||||
|
throw new Error(
|
||||||
|
"REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 실제 REST API 요청을 위한 메서드
|
||||||
|
async executeRequest(
|
||||||
|
endpoint: string,
|
||||||
|
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||||
|
data?: any
|
||||||
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let response: AxiosResponse;
|
let response: AxiosResponse;
|
||||||
|
|
||||||
// HTTP 메서드에 따른 요청 실행
|
// HTTP 메서드에 따른 요청 실행
|
||||||
switch (method.toUpperCase()) {
|
switch (method.toUpperCase()) {
|
||||||
case 'GET':
|
case "GET":
|
||||||
response = await this.httpClient.get(endpoint);
|
response = await this.httpClient.get(endpoint);
|
||||||
break;
|
break;
|
||||||
case 'POST':
|
case "POST":
|
||||||
response = await this.httpClient.post(endpoint, data);
|
response = await this.httpClient.post(endpoint, data);
|
||||||
break;
|
break;
|
||||||
case 'PUT':
|
case "PUT":
|
||||||
response = await this.httpClient.put(endpoint, data);
|
response = await this.httpClient.put(endpoint, data);
|
||||||
break;
|
break;
|
||||||
case 'DELETE':
|
case "DELETE":
|
||||||
response = await this.httpClient.delete(endpoint);
|
response = await this.httpClient.delete(endpoint);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||||
type: typeof responseData,
|
type: typeof responseData,
|
||||||
isArray: Array.isArray(responseData),
|
isArray: Array.isArray(responseData),
|
||||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
keys:
|
||||||
responseData: responseData
|
typeof responseData === "object"
|
||||||
|
? Object.keys(responseData)
|
||||||
|
: "not object",
|
||||||
|
responseData: responseData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 응답 데이터 처리
|
// 응답 데이터 처리
|
||||||
let rows: any[];
|
let rows: any[];
|
||||||
if (Array.isArray(responseData)) {
|
if (Array.isArray(responseData)) {
|
||||||
rows = responseData;
|
rows = responseData;
|
||||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
responseData.data &&
|
||||||
|
Array.isArray(responseData.data)
|
||||||
|
) {
|
||||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||||
rows = responseData.data;
|
rows = responseData.data;
|
||||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
responseData.data &&
|
||||||
|
typeof responseData.data === "object"
|
||||||
|
) {
|
||||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||||
rows = [responseData.data];
|
rows = [responseData.data];
|
||||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
} else if (
|
||||||
|
responseData &&
|
||||||
|
typeof responseData === "object" &&
|
||||||
|
!Array.isArray(responseData)
|
||||||
|
) {
|
||||||
// 단일 객체 응답인 경우
|
// 단일 객체 응답인 경우
|
||||||
rows = [responseData];
|
rows = [responseData];
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
|
|
||||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||||
rowsLength: rows.length,
|
rowsLength: rows.length,
|
||||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
firstRow: rows.length > 0 ? rows[0] : "no data",
|
||||||
allRows: rows
|
allRows: rows,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||||
|
|
@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
method,
|
method,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
executionTime: `${executionTime}ms`
|
executionTime: `${executionTime}ms`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: rows,
|
rows: rows,
|
||||||
rowCount: rows.length,
|
rowCount: rows.length,
|
||||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
fields:
|
||||||
|
rows.length > 0
|
||||||
|
? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" }))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
console.error(
|
||||||
|
`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
throw new Error(
|
||||||
|
`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
throw new Error(
|
||||||
|
`REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
// 일반적인 REST API 엔드포인트들을 반환
|
// 일반적인 REST API 엔드포인트들을 반환
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
table_name: '/api/users',
|
table_name: "/api/users",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '사용자 정보 API'
|
description: "사용자 정보 API",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table_name: '/api/data',
|
table_name: "/api/data",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '기본 데이터 API'
|
description: "기본 데이터 API",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table_name: '/api/custom',
|
table_name: "/api/custom",
|
||||||
columns: [],
|
columns: [],
|
||||||
description: '사용자 정의 엔드포인트'
|
description: "사용자 정의 엔드포인트",
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
async getColumns(endpoint: string): Promise<any[]> {
|
async getColumns(endpoint: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||||
const result = await this.executeQuery(endpoint, 'GET');
|
const result = await this.executeRequest(endpoint, "GET");
|
||||||
|
|
||||||
if (result.rows.length > 0) {
|
if (result.rows.length > 0) {
|
||||||
const sampleRow = result.rows[0];
|
const sampleRow = result.rows[0];
|
||||||
return Object.keys(sampleRow).map(key => ({
|
return Object.keys(sampleRow).map((key) => ({
|
||||||
column_name: key,
|
column_name: key,
|
||||||
data_type: typeof sampleRow[key],
|
data_type: typeof sampleRow[key],
|
||||||
is_nullable: 'YES',
|
is_nullable: "YES",
|
||||||
column_default: null,
|
column_default: null,
|
||||||
description: `${key} 필드`
|
description: `${key} 필드`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
console.error(
|
||||||
|
`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`,
|
||||||
|
error
|
||||||
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST API 전용 메서드들
|
// REST API 전용 메서드들
|
||||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
async getData(
|
||||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
endpoint: string,
|
||||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
params?: Record<string, any>
|
||||||
|
): Promise<any[]> {
|
||||||
|
const queryString = params
|
||||||
|
? "?" + new URLSearchParams(params).toString()
|
||||||
|
: "";
|
||||||
|
const result = await this.executeRequest(endpoint + queryString, "GET");
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async postData(endpoint: string, data: any): Promise<any> {
|
async postData(endpoint: string, data: any): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
const result = await this.executeRequest(endpoint, "POST", data);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async putData(endpoint: string, data: any): Promise<any> {
|
async putData(endpoint: string, data: any): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
const result = await this.executeRequest(endpoint, "PUT", data);
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteData(endpoint: string): Promise<any> {
|
async deleteData(endpoint: string): Promise<any> {
|
||||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
const result = await this.executeRequest(endpoint, "DELETE");
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
/**
|
||||||
|
* PostgreSQL Raw Query 기반 데이터베이스 매니저
|
||||||
|
*
|
||||||
|
* Prisma → Raw Query 전환의 핵심 모듈
|
||||||
|
* - Connection Pool 기반 안정적인 연결 관리
|
||||||
|
* - 트랜잭션 지원
|
||||||
|
* - 타입 안전성 보장
|
||||||
|
* - 자동 재연결 및 에러 핸들링
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pool,
|
||||||
|
PoolClient,
|
||||||
|
QueryResult as PgQueryResult,
|
||||||
|
QueryResultRow,
|
||||||
|
} from "pg";
|
||||||
|
import config from "../config/environment";
|
||||||
|
|
||||||
|
// PostgreSQL 연결 풀
|
||||||
|
let pool: Pool | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 연결 풀 초기화
|
||||||
|
*/
|
||||||
|
export const initializePool = (): Pool => {
|
||||||
|
if (pool) {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATABASE_URL 파싱 (postgresql://user:password@host:port/database)
|
||||||
|
const databaseUrl = config.databaseUrl;
|
||||||
|
|
||||||
|
// URL 파싱 로직
|
||||||
|
const dbConfig = parseDatabaseUrl(databaseUrl);
|
||||||
|
|
||||||
|
pool = new Pool({
|
||||||
|
host: dbConfig.host,
|
||||||
|
port: dbConfig.port,
|
||||||
|
database: dbConfig.database,
|
||||||
|
user: dbConfig.user,
|
||||||
|
password: dbConfig.password,
|
||||||
|
|
||||||
|
// 연결 풀 설정
|
||||||
|
min: config.nodeEnv === "production" ? 5 : 2,
|
||||||
|
max: config.nodeEnv === "production" ? 20 : 10,
|
||||||
|
|
||||||
|
// 타임아웃 설정
|
||||||
|
connectionTimeoutMillis: 30000, // 30초
|
||||||
|
idleTimeoutMillis: 600000, // 10분
|
||||||
|
|
||||||
|
// 연결 유지 설정
|
||||||
|
keepAlive: true,
|
||||||
|
keepAliveInitialDelayMillis: 10000,
|
||||||
|
|
||||||
|
// 쿼리 타임아웃
|
||||||
|
statement_timeout: 60000, // 60초 (동적 테이블 생성 등 고려)
|
||||||
|
query_timeout: 60000,
|
||||||
|
|
||||||
|
// Application Name
|
||||||
|
application_name: "WACE-PLM-Backend",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 풀 이벤트 핸들러
|
||||||
|
pool.on("connect", (client) => {
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("✅ PostgreSQL 클라이언트 연결 생성");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on("acquire", (client) => {
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("🔒 PostgreSQL 클라이언트 획득");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on("remove", (client) => {
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("🗑️ PostgreSQL 클라이언트 제거");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on("error", (err, client) => {
|
||||||
|
console.error("❌ PostgreSQL 연결 풀 에러:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🚀 PostgreSQL 연결 풀 초기화 완료: ${dbConfig.host}:${dbConfig.port}/${dbConfig.database}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DATABASE_URL 파싱 헬퍼 함수
|
||||||
|
*/
|
||||||
|
function parseDatabaseUrl(url: string) {
|
||||||
|
// postgresql://user:password@host:port/database
|
||||||
|
const regex = /postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
// URL 파싱 실패 시 기본값 사용
|
||||||
|
console.warn("⚠️ DATABASE_URL 파싱 실패, 기본값 사용");
|
||||||
|
return {
|
||||||
|
host: "localhost",
|
||||||
|
port: 5432,
|
||||||
|
database: "ilshin",
|
||||||
|
user: "postgres",
|
||||||
|
password: "postgres",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: decodeURIComponent(match[1]),
|
||||||
|
password: decodeURIComponent(match[2]),
|
||||||
|
host: match[3],
|
||||||
|
port: parseInt(match[4], 10),
|
||||||
|
database: match[5],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 가져오기
|
||||||
|
*/
|
||||||
|
export const getPool = (): Pool => {
|
||||||
|
if (!pool) {
|
||||||
|
return initializePool();
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 쿼리 실행 함수
|
||||||
|
*
|
||||||
|
* @param text SQL 쿼리 문자열 (Parameterized Query)
|
||||||
|
* @param params 쿼리 파라미터 배열
|
||||||
|
* @returns 쿼리 결과 배열
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const users = await query<User>('SELECT * FROM users WHERE user_id = $1', ['user123']);
|
||||||
|
*/
|
||||||
|
export async function query<T extends QueryResultRow = any>(
|
||||||
|
text: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<T[]> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result: PgQueryResult<T> = await client.query(text, params);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("🔍 쿼리 실행:", {
|
||||||
|
query: text,
|
||||||
|
params,
|
||||||
|
rowCount: result.rowCount,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 쿼리 실행 실패:", {
|
||||||
|
query: text,
|
||||||
|
params,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 행 조회 쿼리 (결과가 없으면 null 반환)
|
||||||
|
*
|
||||||
|
* @param text SQL 쿼리 문자열
|
||||||
|
* @param params 쿼리 파라미터
|
||||||
|
* @returns 단일 행 또는 null
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const user = await queryOne<User>('SELECT * FROM users WHERE user_id = $1', ['user123']);
|
||||||
|
*/
|
||||||
|
export async function queryOne<T extends QueryResultRow = any>(
|
||||||
|
text: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트랜잭션 실행 함수
|
||||||
|
*
|
||||||
|
* @param callback 트랜잭션 내에서 실행할 함수
|
||||||
|
* @returns 콜백 함수의 반환값
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = await transaction(async (client) => {
|
||||||
|
* await client.query('INSERT INTO users (...) VALUES (...)', []);
|
||||||
|
* await client.query('INSERT INTO user_roles (...) VALUES (...)', []);
|
||||||
|
* return { success: true };
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export async function transaction<T>(
|
||||||
|
callback: (client: PoolClient) => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("🔄 트랜잭션 시작");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callback(client);
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
if (config.debug) {
|
||||||
|
console.log("✅ 트랜잭션 커밋 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
|
||||||
|
console.error("❌ 트랜잭션 롤백:", error.message);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 종료 (앱 종료 시 호출)
|
||||||
|
*/
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
if (pool) {
|
||||||
|
await pool.end();
|
||||||
|
pool = null;
|
||||||
|
console.log("🛑 PostgreSQL 연결 풀 종료");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 상태 확인
|
||||||
|
*/
|
||||||
|
export function getPoolStatus() {
|
||||||
|
const pool = getPool();
|
||||||
|
return {
|
||||||
|
totalCount: pool.totalCount,
|
||||||
|
idleCount: pool.idleCount,
|
||||||
|
waitingCount: pool.waitingCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool 직접 접근 (필요한 경우)
|
||||||
|
export { pool };
|
||||||
|
|
||||||
|
// 기본 익스포트 (편의성)
|
||||||
|
export default {
|
||||||
|
query,
|
||||||
|
queryOne,
|
||||||
|
transaction,
|
||||||
|
getPool,
|
||||||
|
initializePool,
|
||||||
|
closePool,
|
||||||
|
getPoolStatus,
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
host: string;
|
host: string;
|
||||||
|
|
@ -15,13 +15,15 @@ export interface QueryResult {
|
||||||
rows: any[];
|
rows: any[];
|
||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
fields?: any[];
|
fields?: any[];
|
||||||
|
affectedRows?: number; // MySQL/MariaDB용
|
||||||
|
length?: number; // 배열 형태로 반환되는 경우
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseConnector {
|
export interface DatabaseConnector {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
disconnect(): Promise<void>;
|
disconnect(): Promise<void>;
|
||||||
testConnection(): Promise<ConnectionTestResult>;
|
testConnection(): Promise<ConnectionTestResult>;
|
||||||
executeQuery(query: string): Promise<QueryResult>;
|
executeQuery(query: string, params?: any[]): Promise<QueryResult>; // params 추가
|
||||||
getTables(): Promise<TableInfo[]>;
|
getTables(): Promise<TableInfo[]>;
|
||||||
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
||||||
}
|
}
|
||||||
|
|
@ -25,16 +25,25 @@ export const errorHandler = (
|
||||||
let error = { ...err };
|
let error = { ...err };
|
||||||
error.message = err.message;
|
error.message = err.message;
|
||||||
|
|
||||||
// Prisma 에러 처리
|
// PostgreSQL 에러 처리 (pg 라이브러리)
|
||||||
if (err.name === "PrismaClientKnownRequestError") {
|
if ((err as any).code) {
|
||||||
const message = "데이터베이스 요청 오류가 발생했습니다.";
|
const pgError = err as any;
|
||||||
error = new AppError(message, 400);
|
// PostgreSQL 에러 코드 참조: https://www.postgresql.org/docs/current/errcodes-appendix.html
|
||||||
|
if (pgError.code === "23505") {
|
||||||
|
// unique_violation
|
||||||
|
error = new AppError("중복된 데이터가 존재합니다.", 400);
|
||||||
|
} else if (pgError.code === "23503") {
|
||||||
|
// foreign_key_violation
|
||||||
|
error = new AppError("참조 무결성 제약 조건 위반입니다.", 400);
|
||||||
|
} else if (pgError.code === "23502") {
|
||||||
|
// not_null_violation
|
||||||
|
error = new AppError("필수 입력값이 누락되었습니다.", 400);
|
||||||
|
} else if (pgError.code.startsWith("23")) {
|
||||||
|
// 기타 무결성 제약 조건 위반
|
||||||
|
error = new AppError("데이터 무결성 제약 조건 위반입니다.", 400);
|
||||||
|
} else {
|
||||||
|
error = new AppError("데이터베이스 오류가 발생했습니다.", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prisma 유효성 검증 에러
|
|
||||||
if (err.name === "PrismaClientValidationError") {
|
|
||||||
const message = "입력 데이터가 유효하지 않습니다.";
|
|
||||||
error = new AppError(message, 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT 에러 처리
|
// JWT 에러 처리
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,8 @@ export const requireSuperAdmin = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 슈퍼관리자 권한 확인 (회사코드가 '*'이고 plm_admin 사용자)
|
// 슈퍼관리자 권한 확인 (회사코드가 '*'인 사용자)
|
||||||
if (req.user.companyCode !== "*" || req.user.userId !== "plm_admin") {
|
if (req.user.companyCode !== "*") {
|
||||||
logger.warn("DDL 실행 시도 - 권한 부족", {
|
logger.warn("DDL 실행 시도 - 권한 부족", {
|
||||||
userId: req.user.userId,
|
userId: req.user.userId,
|
||||||
companyCode: req.user.companyCode,
|
companyCode: req.user.companyCode,
|
||||||
|
|
@ -62,7 +62,7 @@ export const requireSuperAdmin = (
|
||||||
error: {
|
error: {
|
||||||
code: "SUPER_ADMIN_REQUIRED",
|
code: "SUPER_ADMIN_REQUIRED",
|
||||||
details:
|
details:
|
||||||
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 plm_admin 사용자만 가능합니다.",
|
"최고 관리자 권한이 필요합니다. DDL 실행은 회사코드가 '*'인 사용자만 가능합니다.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -167,7 +167,7 @@ export const validateDDLPermission = (
|
||||||
* 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수
|
* 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수
|
||||||
*/
|
*/
|
||||||
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
|
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
|
||||||
return user?.companyCode === "*" && user?.userId === "plm_admin";
|
return user?.companyCode === "*";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,9 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
@ -29,9 +28,10 @@ router.delete(
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. 회사 존재 확인
|
// 1. 회사 존재 확인
|
||||||
const existingCompany = await prisma.company_mng.findUnique({
|
const existingCompany = await queryOne<any>(
|
||||||
where: { company_code: companyCode },
|
`SELECT * FROM company_mng WHERE company_code = $1`,
|
||||||
});
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingCompany) {
|
if (!existingCompany) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
|
@ -58,12 +58,10 @@ router.delete(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 데이터베이스에서 회사 삭제 (soft delete)
|
// 3. 데이터베이스에서 회사 삭제 (soft delete)
|
||||||
await prisma.company_mng.update({
|
await query(
|
||||||
where: { company_code: companyCode },
|
`UPDATE company_mng SET status = 'deleted' WHERE company_code = $1`,
|
||||||
data: {
|
[companyCode]
|
||||||
status: "deleted",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("회사 삭제 완료", {
|
logger.info("회사 삭제 완료", {
|
||||||
companyCode,
|
companyCode,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { DashboardController } from '../controllers/DashboardController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const dashboardController = new DashboardController();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 API 라우트
|
||||||
|
*
|
||||||
|
* 모든 엔드포인트는 인증이 필요하지만,
|
||||||
|
* 공개 대시보드 조회는 인증 없이도 가능
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 공개 대시보드 목록 조회 (인증 불필요)
|
||||||
|
router.get('/public', dashboardController.getDashboards.bind(dashboardController));
|
||||||
|
|
||||||
|
// 공개 대시보드 상세 조회 (인증 불필요)
|
||||||
|
router.get('/public/:id', dashboardController.getDashboard.bind(dashboardController));
|
||||||
|
|
||||||
|
// 쿼리 실행 (인증 불필요 - 개발용)
|
||||||
|
router.post('/execute-query', dashboardController.executeQuery.bind(dashboardController));
|
||||||
|
|
||||||
|
// 인증이 필요한 라우트들
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 내 대시보드 목록 조회
|
||||||
|
router.get('/my', dashboardController.getMyDashboards.bind(dashboardController));
|
||||||
|
|
||||||
|
// 대시보드 CRUD
|
||||||
|
router.post('/', dashboardController.createDashboard.bind(dashboardController));
|
||||||
|
router.get('/', dashboardController.getDashboards.bind(dashboardController));
|
||||||
|
router.get('/:id', dashboardController.getDashboard.bind(dashboardController));
|
||||||
|
router.put('/:id', dashboardController.updateDashboard.bind(dashboardController));
|
||||||
|
router.delete('/:id', dashboardController.deleteDashboard.bind(dashboardController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import {
|
||||||
|
authenticateToken,
|
||||||
|
AuthenticatedRequest,
|
||||||
|
} from "../../middleware/authMiddleware";
|
||||||
|
import { ExternalDbConnectionService } from "../../services/externalDbConnectionService";
|
||||||
|
import { ExternalDbConnectionFilter } from "../../types/externalDbTypes";
|
||||||
|
import logger from "../../utils/logger";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dataflow/node-external-connections/tested
|
||||||
|
* 노드 플로우용: 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/tested",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
logger.info("🔍 노드 플로우용 테스트 완료된 커넥션 조회 요청");
|
||||||
|
|
||||||
|
// 활성 상태의 외부 커넥션 조회
|
||||||
|
const filter: ExternalDbConnectionFilter = {
|
||||||
|
is_active: "Y",
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalConnections =
|
||||||
|
await ExternalDbConnectionService.getConnections(filter);
|
||||||
|
|
||||||
|
if (!externalConnections.success) {
|
||||||
|
return res.status(400).json(externalConnections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 커넥션들에 대해 연결 테스트 수행 (제한된 병렬 처리 + 타임아웃 관리)
|
||||||
|
const validExternalConnections: any[] = [];
|
||||||
|
const connections = externalConnections.data || [];
|
||||||
|
const MAX_CONCURRENT = 3; // 최대 동시 연결 수
|
||||||
|
const TIMEOUT_MS = 3000; // 타임아웃 3초
|
||||||
|
|
||||||
|
// 청크 단위로 처리 (최대 3개씩)
|
||||||
|
for (let i = 0; i < connections.length; i += MAX_CONCURRENT) {
|
||||||
|
const chunk = connections.slice(i, i + MAX_CONCURRENT);
|
||||||
|
|
||||||
|
const chunkResults = await Promise.allSettled(
|
||||||
|
chunk.map(async (connection) => {
|
||||||
|
let testPromise: Promise<any> | null = null;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 타임아웃과 함께 테스트 실행
|
||||||
|
testPromise = ExternalDbConnectionService.testConnectionById(
|
||||||
|
connection.id!
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error("연결 테스트 타임아웃"));
|
||||||
|
}, TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
const testResult = await Promise.race([
|
||||||
|
testPromise,
|
||||||
|
timeoutPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 타임아웃 정리
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (testResult.success) {
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
connection_name: connection.connection_name,
|
||||||
|
description: connection.description,
|
||||||
|
db_type: connection.db_type,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database_name: connection.database_name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
// 타임아웃 정리
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// 🔥 타임아웃 시 연결 강제 해제
|
||||||
|
try {
|
||||||
|
const { DatabaseConnectorFactory } = await import(
|
||||||
|
"../../database/DatabaseConnectorFactory"
|
||||||
|
);
|
||||||
|
await DatabaseConnectorFactory.closeConnector(
|
||||||
|
connection.id!,
|
||||||
|
connection.db_type
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`🧹 타임아웃/실패로 인한 커넥션 정리 완료: ${connection.connection_name}`
|
||||||
|
);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
logger.warn(
|
||||||
|
`커넥션 정리 실패 (ID: ${connection.id}):`,
|
||||||
|
cleanupError instanceof Error
|
||||||
|
? cleanupError.message
|
||||||
|
: cleanupError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// fulfilled 결과만 수집
|
||||||
|
chunkResults.forEach((result) => {
|
||||||
|
if (result.status === "fulfilled" && result.value !== null) {
|
||||||
|
validExternalConnections.push(result.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 다음 청크 처리 전 짧은 대기 (연결 풀 안정화)
|
||||||
|
if (i + MAX_CONCURRENT < connections.length) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`✅ 테스트 성공한 커넥션: ${validExternalConnections.length}/${externalConnections.data?.length || 0}개`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: validExternalConnections,
|
||||||
|
message: `테스트에 성공한 ${validExternalConnections.length}개의 커넥션을 조회했습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("노드 플로우용 커넥션 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dataflow/node-external-connections/:id/tables
|
||||||
|
* 특정 외부 DB의 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/tables",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 연결 ID입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🔍 외부 DB 테이블 목록 조회: connectionId=${id}`);
|
||||||
|
|
||||||
|
const result =
|
||||||
|
await ExternalDbConnectionService.getTablesFromConnection(id);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/dataflow/node-external-connections/:id/tables/:tableName/columns
|
||||||
|
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:id/tables/:tableName/columns",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { tableName } = req.params;
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 연결 ID입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔍 외부 DB 컬럼 목록 조회: connectionId=${id}, table=${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await ExternalDbConnectionService.getColumnsFromConnection(
|
||||||
|
id,
|
||||||
|
tableName
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
/**
|
||||||
|
* 노드 기반 데이터 플로우 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { query, queryOne } from "../../database/db";
|
||||||
|
import { logger } from "../../utils/logger";
|
||||||
|
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 목록 조회
|
||||||
|
*/
|
||||||
|
router.get("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const flows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
flow_id as "flowId",
|
||||||
|
flow_name as "flowName",
|
||||||
|
flow_description as "flowDescription",
|
||||||
|
created_at as "createdAt",
|
||||||
|
updated_at as "updatedAt"
|
||||||
|
FROM node_flows
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: flows,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 목록 조회 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 목록을 조회하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 상세 조회
|
||||||
|
*/
|
||||||
|
router.get("/:flowId", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
|
||||||
|
const flow = await queryOne(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
flow_id as "flowId",
|
||||||
|
flow_name as "flowName",
|
||||||
|
flow_description as "flowDescription",
|
||||||
|
flow_data as "flowData",
|
||||||
|
created_at as "createdAt",
|
||||||
|
updated_at as "updatedAt"
|
||||||
|
FROM node_flows
|
||||||
|
WHERE flow_id = $1
|
||||||
|
`,
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flow) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: flow,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 조회 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 조회하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 저장 (신규)
|
||||||
|
*/
|
||||||
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowName, flowDescription, flowData } = req.body;
|
||||||
|
|
||||||
|
if (!flowName || !flowData) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 이름과 데이터는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne(
|
||||||
|
`
|
||||||
|
INSERT INTO node_flows (flow_name, flow_description, flow_data)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING flow_id as "flowId"
|
||||||
|
`,
|
||||||
|
[flowName, flowDescription || "", flowData]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`플로우 저장 성공: ${result.flowId}`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "플로우가 저장되었습니다.",
|
||||||
|
data: {
|
||||||
|
flowId: result.flowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 저장 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 저장하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 수정
|
||||||
|
*/
|
||||||
|
router.put("/", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId, flowName, flowDescription, flowData } = req.body;
|
||||||
|
|
||||||
|
if (!flowId || !flowName || !flowData) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우 ID, 이름, 데이터는 필수입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE node_flows
|
||||||
|
SET flow_name = $1,
|
||||||
|
flow_description = $2,
|
||||||
|
flow_data = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE flow_id = $4
|
||||||
|
`,
|
||||||
|
[flowName, flowDescription || "", flowData, flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`플로우 수정 성공: ${flowId}`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "플로우가 수정되었습니다.",
|
||||||
|
data: {
|
||||||
|
flowId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 수정 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 수정하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 삭제
|
||||||
|
*/
|
||||||
|
router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
DELETE FROM node_flows
|
||||||
|
WHERE flow_id = $1
|
||||||
|
`,
|
||||||
|
[flowId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`플로우 삭제 성공: ${flowId}`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "플로우가 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 삭제 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "플로우를 삭제하지 못했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 실행
|
||||||
|
* POST /api/dataflow/node-flows/:flowId/execute
|
||||||
|
*/
|
||||||
|
router.post("/:flowId/execute", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
const contextData = req.body;
|
||||||
|
|
||||||
|
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||||
|
contextDataKeys: Object.keys(contextData),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 플로우 실행
|
||||||
|
const result = await NodeFlowExecutionService.executeFlow(
|
||||||
|
parseInt(flowId, 10),
|
||||||
|
contextData
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("플로우 실행 실패:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "플로우 실행 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -21,6 +21,8 @@ import {
|
||||||
testConditionalConnection,
|
testConditionalConnection,
|
||||||
executeConditionalActions,
|
executeConditionalActions,
|
||||||
} from "../controllers/conditionalConnectionController";
|
} from "../controllers/conditionalConnectionController";
|
||||||
|
import nodeFlowsRouter from "./dataflow/node-flows";
|
||||||
|
import nodeExternalConnectionsRouter from "./dataflow/node-external-connections";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -146,4 +148,16 @@ router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
||||||
*/
|
*/
|
||||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노드 기반 플로우 관리
|
||||||
|
* /api/dataflow/node-flows/*
|
||||||
|
*/
|
||||||
|
router.use("/node-flows", nodeFlowsRouter);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 노드 플로우용 외부 DB 커넥션 관리
|
||||||
|
* /api/dataflow/node-external-connections/*
|
||||||
|
*/
|
||||||
|
router.use("/node-external-connections", nodeExternalConnectionsRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
validateDDLPermission,
|
validateDDLPermission,
|
||||||
} from "../middleware/superAdminMiddleware";
|
} from "../middleware/superAdminMiddleware";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -180,11 +181,7 @@ router.get("/info", authenticateToken, requireSuperAdmin, (req, res) => {
|
||||||
router.get("/health", authenticateToken, async (req, res) => {
|
router.get("/health", authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// 기본적인 데이터베이스 연결 테스트
|
// 기본적인 데이터베이스 연결 테스트
|
||||||
const { PrismaClient } = await import("@prisma/client");
|
await query("SELECT 1");
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
await prisma.$disconnect();
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailAccountFileController } from '../controllers/mailAccountFileController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 메일 계정 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
router.get('/', (req, res) => mailAccountFileController.getAllAccounts(req, res));
|
||||||
|
router.get('/:id', (req, res) => mailAccountFileController.getAccountById(req, res));
|
||||||
|
router.post('/', (req, res) => mailAccountFileController.createAccount(req, res));
|
||||||
|
router.put('/:id', (req, res) => mailAccountFileController.updateAccount(req, res));
|
||||||
|
router.delete('/:id', (req, res) => mailAccountFileController.deleteAccount(req, res));
|
||||||
|
router.post('/:id/test-connection', (req, res) => mailAccountFileController.testConnection(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* 메일 수신 라우트 (Step 2 - 기본 구현)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import { MailReceiveBasicController } from '../controllers/mailReceiveBasicController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 모든 메일 수신 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
const controller = new MailReceiveBasicController();
|
||||||
|
|
||||||
|
// 메일 목록 조회
|
||||||
|
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||||
|
|
||||||
|
// 메일 상세 조회
|
||||||
|
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||||
|
|
||||||
|
// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
|
||||||
|
router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
|
||||||
|
|
||||||
|
// 메일 읽음 표시
|
||||||
|
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||||
|
|
||||||
|
// IMAP 연결 테스트
|
||||||
|
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 메일 발송 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// POST /api/mail/send/simple - 메일 발송
|
||||||
|
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
|
||||||
|
|
||||||
|
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||||
|
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 메일 템플릿 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 템플릿 CRUD
|
||||||
|
router.get('/', (req, res) => mailTemplateFileController.getAllTemplates(req, res));
|
||||||
|
router.get('/:id', (req, res) => mailTemplateFileController.getTemplateById(req, res));
|
||||||
|
router.post('/', (req, res) => mailTemplateFileController.createTemplate(req, res));
|
||||||
|
router.put('/:id', (req, res) => mailTemplateFileController.updateTemplate(req, res));
|
||||||
|
router.delete('/:id', (req, res) => mailTemplateFileController.deleteTemplate(req, res));
|
||||||
|
|
||||||
|
// 미리보기
|
||||||
|
router.post('/:id/preview', (req, res) => mailTemplateFileController.previewTemplate(req, res));
|
||||||
|
router.post('/:id/preview-with-query', (req, res) => mailTemplateFileController.previewWithQuery(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { PostgreSQLService } from '../database/PostgreSQLService';
|
||||||
|
import {
|
||||||
|
Dashboard,
|
||||||
|
DashboardElement,
|
||||||
|
CreateDashboardRequest,
|
||||||
|
UpdateDashboardRequest,
|
||||||
|
DashboardListQuery
|
||||||
|
} from '../types/dashboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 서비스 - Raw Query 방식
|
||||||
|
* PostgreSQL 직접 연결을 통한 CRUD 작업
|
||||||
|
*/
|
||||||
|
export class DashboardService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 생성
|
||||||
|
*/
|
||||||
|
static async createDashboard(data: CreateDashboardRequest, userId: string): Promise<Dashboard> {
|
||||||
|
const dashboardId = uuidv4();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 트랜잭션으로 대시보드와 요소들을 함께 생성
|
||||||
|
const result = await PostgreSQLService.transaction(async (client) => {
|
||||||
|
// 1. 대시보드 메인 정보 저장
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO dashboards (
|
||||||
|
id, title, description, is_public, created_by,
|
||||||
|
created_at, updated_at, tags, category, view_count
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
`, [
|
||||||
|
dashboardId,
|
||||||
|
data.title,
|
||||||
|
data.description || null,
|
||||||
|
data.isPublic || false,
|
||||||
|
userId,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
JSON.stringify(data.tags || []),
|
||||||
|
data.category || null,
|
||||||
|
0
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 대시보드 요소들 저장
|
||||||
|
if (data.elements && data.elements.length > 0) {
|
||||||
|
for (let i = 0; i < data.elements.length; i++) {
|
||||||
|
const element = data.elements[i];
|
||||||
|
const elementId = uuidv4(); // 항상 새로운 UUID 생성
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO dashboard_elements (
|
||||||
|
id, dashboard_id, element_type, element_subtype,
|
||||||
|
position_x, position_y, width, height,
|
||||||
|
title, content, data_source_config, chart_config,
|
||||||
|
display_order, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
`, [
|
||||||
|
elementId,
|
||||||
|
dashboardId,
|
||||||
|
element.type,
|
||||||
|
element.subtype,
|
||||||
|
element.position.x,
|
||||||
|
element.position.y,
|
||||||
|
element.size.width,
|
||||||
|
element.size.height,
|
||||||
|
element.title,
|
||||||
|
element.content || null,
|
||||||
|
JSON.stringify(element.dataSource || {}),
|
||||||
|
JSON.stringify(element.chartConfig || {}),
|
||||||
|
i,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dashboardId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 생성된 대시보드 반환
|
||||||
|
try {
|
||||||
|
const dashboard = await this.getDashboardById(dashboardId, userId);
|
||||||
|
if (!dashboard) {
|
||||||
|
console.error('대시보드 생성은 성공했으나 조회에 실패:', dashboardId);
|
||||||
|
// 생성은 성공했으므로 기본 정보만이라도 반환
|
||||||
|
return {
|
||||||
|
id: dashboardId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
thumbnailUrl: undefined,
|
||||||
|
isPublic: data.isPublic || false,
|
||||||
|
createdBy: userId,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
tags: data.tags || [],
|
||||||
|
category: data.category,
|
||||||
|
viewCount: 0,
|
||||||
|
elements: data.elements || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return dashboard;
|
||||||
|
} catch (fetchError) {
|
||||||
|
console.error('생성된 대시보드 조회 중 오류:', fetchError);
|
||||||
|
// 생성은 성공했으므로 기본 정보 반환
|
||||||
|
return {
|
||||||
|
id: dashboardId,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
thumbnailUrl: undefined,
|
||||||
|
isPublic: data.isPublic || false,
|
||||||
|
createdBy: userId,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
tags: data.tags || [],
|
||||||
|
category: data.category,
|
||||||
|
viewCount: 0,
|
||||||
|
elements: data.elements || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard creation error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 목록 조회
|
||||||
|
*/
|
||||||
|
static async getDashboards(query: DashboardListQuery, userId?: string) {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
search,
|
||||||
|
category,
|
||||||
|
isPublic,
|
||||||
|
createdBy
|
||||||
|
} = query;
|
||||||
|
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 기본 WHERE 조건
|
||||||
|
let whereConditions = ['d.deleted_at IS NULL'];
|
||||||
|
let params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 권한 필터링
|
||||||
|
if (userId) {
|
||||||
|
whereConditions.push(`(d.created_by = $${paramIndex} OR d.is_public = true)`);
|
||||||
|
params.push(userId);
|
||||||
|
paramIndex++;
|
||||||
|
} else {
|
||||||
|
whereConditions.push('d.is_public = true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 조건
|
||||||
|
if (search) {
|
||||||
|
whereConditions.push(`(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})`);
|
||||||
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
|
paramIndex += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 필터
|
||||||
|
if (category) {
|
||||||
|
whereConditions.push(`d.category = $${paramIndex}`);
|
||||||
|
params.push(category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공개/비공개 필터
|
||||||
|
if (typeof isPublic === 'boolean') {
|
||||||
|
whereConditions.push(`d.is_public = $${paramIndex}`);
|
||||||
|
params.push(isPublic);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 작성자 필터
|
||||||
|
if (createdBy) {
|
||||||
|
whereConditions.push(`d.created_by = $${paramIndex}`);
|
||||||
|
params.push(createdBy);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.join(' AND ');
|
||||||
|
|
||||||
|
// 대시보드 목록 조회 (users 테이블 조인 제거)
|
||||||
|
const dashboardQuery = `
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.title,
|
||||||
|
d.description,
|
||||||
|
d.thumbnail_url,
|
||||||
|
d.is_public,
|
||||||
|
d.created_by,
|
||||||
|
d.created_at,
|
||||||
|
d.updated_at,
|
||||||
|
d.tags,
|
||||||
|
d.category,
|
||||||
|
d.view_count,
|
||||||
|
COUNT(de.id) as elements_count
|
||||||
|
FROM dashboards d
|
||||||
|
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
|
||||||
|
d.created_by, d.created_at, d.updated_at, d.tags, d.category,
|
||||||
|
d.view_count
|
||||||
|
ORDER BY d.updated_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dashboardResult = await PostgreSQLService.query(
|
||||||
|
dashboardQuery,
|
||||||
|
[...params, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(DISTINCT d.id) as total
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE ${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countResult = await PostgreSQLService.query(countQuery, params);
|
||||||
|
const total = parseInt(countResult.rows[0]?.total || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
dashboards: dashboardResult.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
thumbnailUrl: row.thumbnail_url,
|
||||||
|
isPublic: row.is_public,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
tags: JSON.parse(row.tags || '[]'),
|
||||||
|
category: row.category,
|
||||||
|
viewCount: parseInt(row.view_count || '0'),
|
||||||
|
elementsCount: parseInt(row.elements_count || '0')
|
||||||
|
})),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard list error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 상세 조회
|
||||||
|
*/
|
||||||
|
static async getDashboardById(dashboardId: string, userId?: string): Promise<Dashboard | null> {
|
||||||
|
try {
|
||||||
|
// 1. 대시보드 기본 정보 조회 (권한 체크 포함)
|
||||||
|
let dashboardQuery: string;
|
||||||
|
let dashboardParams: any[];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND (d.created_by = $2 OR d.is_public = true)
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId, userId];
|
||||||
|
} else {
|
||||||
|
dashboardQuery = `
|
||||||
|
SELECT d.*
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND d.is_public = true
|
||||||
|
`;
|
||||||
|
dashboardParams = [dashboardId];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardResult = await PostgreSQLService.query(dashboardQuery, dashboardParams);
|
||||||
|
|
||||||
|
if (dashboardResult.rows.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboard = dashboardResult.rows[0];
|
||||||
|
|
||||||
|
// 2. 대시보드 요소들 조회
|
||||||
|
const elementsQuery = `
|
||||||
|
SELECT * FROM dashboard_elements
|
||||||
|
WHERE dashboard_id = $1
|
||||||
|
ORDER BY display_order ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const elementsResult = await PostgreSQLService.query(elementsQuery, [dashboardId]);
|
||||||
|
|
||||||
|
// 3. 요소 데이터 변환
|
||||||
|
const elements: DashboardElement[] = elementsResult.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
type: row.element_type,
|
||||||
|
subtype: row.element_subtype,
|
||||||
|
position: {
|
||||||
|
x: row.position_x,
|
||||||
|
y: row.position_y
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: row.width,
|
||||||
|
height: row.height
|
||||||
|
},
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
dataSource: JSON.parse(row.data_source_config || '{}'),
|
||||||
|
chartConfig: JSON.parse(row.chart_config || '{}')
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dashboard.id,
|
||||||
|
title: dashboard.title,
|
||||||
|
description: dashboard.description,
|
||||||
|
thumbnailUrl: dashboard.thumbnail_url,
|
||||||
|
isPublic: dashboard.is_public,
|
||||||
|
createdBy: dashboard.created_by,
|
||||||
|
createdAt: dashboard.created_at,
|
||||||
|
updatedAt: dashboard.updated_at,
|
||||||
|
tags: JSON.parse(dashboard.tags || '[]'),
|
||||||
|
category: dashboard.category,
|
||||||
|
viewCount: parseInt(dashboard.view_count || '0'),
|
||||||
|
elements
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard get error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 업데이트
|
||||||
|
*/
|
||||||
|
static async updateDashboard(
|
||||||
|
dashboardId: string,
|
||||||
|
data: UpdateDashboardRequest,
|
||||||
|
userId: string
|
||||||
|
): Promise<Dashboard | null> {
|
||||||
|
try {
|
||||||
|
const result = await PostgreSQLService.transaction(async (client) => {
|
||||||
|
// 권한 체크
|
||||||
|
const authCheckResult = await client.query(`
|
||||||
|
SELECT id FROM dashboards
|
||||||
|
WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL
|
||||||
|
`, [dashboardId, userId]);
|
||||||
|
|
||||||
|
if (authCheckResult.rows.length === 0) {
|
||||||
|
throw new Error('대시보드를 찾을 수 없거나 수정 권한이 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 1. 대시보드 메인 정보 업데이트
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const updateParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
updateFields.push(`title = $${paramIndex}`);
|
||||||
|
updateParams.push(data.title);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex}`);
|
||||||
|
updateParams.push(data.description);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (data.isPublic !== undefined) {
|
||||||
|
updateFields.push(`is_public = $${paramIndex}`);
|
||||||
|
updateParams.push(data.isPublic);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (data.tags !== undefined) {
|
||||||
|
updateFields.push(`tags = $${paramIndex}`);
|
||||||
|
updateParams.push(JSON.stringify(data.tags));
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
updateFields.push(`category = $${paramIndex}`);
|
||||||
|
updateParams.push(data.category);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_at = $${paramIndex}`);
|
||||||
|
updateParams.push(now);
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
updateParams.push(dashboardId);
|
||||||
|
|
||||||
|
if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE dashboards
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client.query(updateQuery, updateParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 요소 업데이트 (있는 경우)
|
||||||
|
if (data.elements) {
|
||||||
|
// 기존 요소들 삭제
|
||||||
|
await client.query(`
|
||||||
|
DELETE FROM dashboard_elements WHERE dashboard_id = $1
|
||||||
|
`, [dashboardId]);
|
||||||
|
|
||||||
|
// 새 요소들 추가
|
||||||
|
for (let i = 0; i < data.elements.length; i++) {
|
||||||
|
const element = data.elements[i];
|
||||||
|
const elementId = uuidv4();
|
||||||
|
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO dashboard_elements (
|
||||||
|
id, dashboard_id, element_type, element_subtype,
|
||||||
|
position_x, position_y, width, height,
|
||||||
|
title, content, data_source_config, chart_config,
|
||||||
|
display_order, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||||
|
`, [
|
||||||
|
elementId,
|
||||||
|
dashboardId,
|
||||||
|
element.type,
|
||||||
|
element.subtype,
|
||||||
|
element.position.x,
|
||||||
|
element.position.y,
|
||||||
|
element.size.width,
|
||||||
|
element.size.height,
|
||||||
|
element.title,
|
||||||
|
element.content || null,
|
||||||
|
JSON.stringify(element.dataSource || {}),
|
||||||
|
JSON.stringify(element.chartConfig || {}),
|
||||||
|
i,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dashboardId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업데이트된 대시보드 반환
|
||||||
|
return await this.getDashboardById(dashboardId, userId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard update error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 삭제 (소프트 삭제)
|
||||||
|
*/
|
||||||
|
static async deleteDashboard(dashboardId: string, userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await PostgreSQLService.query(`
|
||||||
|
UPDATE dashboards
|
||||||
|
SET deleted_at = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL
|
||||||
|
`, [now, now, dashboardId, userId]);
|
||||||
|
|
||||||
|
return (result.rowCount || 0) > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard delete error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회수 증가
|
||||||
|
*/
|
||||||
|
static async incrementViewCount(dashboardId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await PostgreSQLService.query(`
|
||||||
|
UPDATE dashboards
|
||||||
|
SET view_count = view_count + 1
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
`, [dashboardId]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('View count increment error:', error);
|
||||||
|
// 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 권한 체크
|
||||||
|
*/
|
||||||
|
static async checkUserPermission(
|
||||||
|
dashboardId: string,
|
||||||
|
userId: string,
|
||||||
|
requiredPermission: 'view' | 'edit' | 'admin' = 'view'
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await PostgreSQLService.query(`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN d.created_by = $2 THEN 'admin'
|
||||||
|
WHEN d.is_public = true THEN 'view'
|
||||||
|
ELSE 'none'
|
||||||
|
END as permission
|
||||||
|
FROM dashboards d
|
||||||
|
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||||
|
`, [dashboardId, userId]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPermission = result.rows[0].permission;
|
||||||
|
|
||||||
|
// 권한 레벨 체크
|
||||||
|
const permissionLevels = { 'view': 1, 'edit': 2, 'admin': 3 };
|
||||||
|
const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0;
|
||||||
|
const requiredLevel = permissionLevels[requiredPermission];
|
||||||
|
|
||||||
|
return userLevel >= requiredLevel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Permission check error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { query, queryOne } from "../database/db";
|
||||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
|
||||||
import prisma = require("../config/database");
|
|
||||||
|
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,9 +11,10 @@ export class AdminService {
|
||||||
|
|
||||||
const { userLang = "ko" } = paramMap;
|
const { userLang = "ko" } = paramMap;
|
||||||
|
|
||||||
// 기존 Java의 selectAdminMenuList 쿼리를 Prisma로 포팅
|
// 기존 Java의 selectAdminMenuList 쿼리를 Raw Query로 포팅
|
||||||
// WITH RECURSIVE 쿼리를 Prisma의 $queryRaw로 구현
|
// WITH RECURSIVE 쿼리 구현
|
||||||
const menuList = await prisma.$queryRaw<any[]>`
|
const menuList = await query<any>(
|
||||||
|
`
|
||||||
WITH RECURSIVE v_menu(
|
WITH RECURSIVE v_menu(
|
||||||
LEVEL,
|
LEVEL,
|
||||||
MENU_TYPE,
|
MENU_TYPE,
|
||||||
|
|
@ -62,14 +61,14 @@ export class AdminService {
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU.LANG_KEY
|
WHERE MLKM.lang_key = MENU.LANG_KEY
|
||||||
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT MLT.lang_text
|
(SELECT MLT.lang_text
|
||||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU.LANG_KEY
|
WHERE MLKM.lang_key = MENU.LANG_KEY
|
||||||
AND MLKM.company_code = '*'
|
AND MLKM.company_code = '*'
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
MENU.MENU_NAME_KOR
|
MENU.MENU_NAME_KOR
|
||||||
),
|
),
|
||||||
|
|
@ -80,14 +79,14 @@ export class AdminService {
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
||||||
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
AND (MLKM.company_code = MENU.COMPANY_CODE OR (MENU.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT MLT.lang_text
|
(SELECT MLT.lang_text
|
||||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
WHERE MLKM.lang_key = MENU.LANG_KEY_DESC
|
||||||
AND MLKM.company_code = '*'
|
AND MLKM.company_code = '*'
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
MENU.MENU_DESC
|
MENU.MENU_DESC
|
||||||
)
|
)
|
||||||
|
|
@ -125,14 +124,14 @@ export class AdminService {
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
||||||
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT MLT.lang_text
|
(SELECT MLT.lang_text
|
||||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY
|
||||||
AND MLKM.company_code = '*'
|
AND MLKM.company_code = '*'
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
MENU_SUB.MENU_NAME_KOR
|
MENU_SUB.MENU_NAME_KOR
|
||||||
),
|
),
|
||||||
|
|
@ -143,14 +142,14 @@ export class AdminService {
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
||||||
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
AND (MLKM.company_code = MENU_SUB.COMPANY_CODE OR (MENU_SUB.COMPANY_CODE IS NULL AND MLKM.company_code = '*'))
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
(SELECT MLT.lang_text
|
(SELECT MLT.lang_text
|
||||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||||
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
JOIN MULTI_LANG_TEXT MLT ON MLKM.key_id = MLT.key_id
|
||||||
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
WHERE MLKM.lang_key = MENU_SUB.LANG_KEY_DESC
|
||||||
AND MLKM.company_code = '*'
|
AND MLKM.company_code = '*'
|
||||||
AND MLT.lang_code = ${userLang}
|
AND MLT.lang_code = $1
|
||||||
LIMIT 1),
|
LIMIT 1),
|
||||||
MENU_SUB.MENU_DESC
|
MENU_SUB.MENU_DESC
|
||||||
)
|
)
|
||||||
|
|
@ -190,7 +189,9 @@ export class AdminService {
|
||||||
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
||||||
WHERE 1 = 1
|
WHERE 1 = 1
|
||||||
ORDER BY PATH, SEQ
|
ORDER BY PATH, SEQ
|
||||||
`;
|
`,
|
||||||
|
[userLang]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
logger.info(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||||
if (menuList.length > 0) {
|
if (menuList.length > 0) {
|
||||||
|
|
@ -213,8 +214,9 @@ export class AdminService {
|
||||||
|
|
||||||
const { userLang = "ko" } = paramMap;
|
const { userLang = "ko" } = paramMap;
|
||||||
|
|
||||||
// 기존 Java의 selectUserMenuList 쿼리를 Prisma로 포팅
|
// 기존 Java의 selectUserMenuList 쿼리를 Raw Query로 포팅
|
||||||
const menuList = await prisma.$queryRaw<any[]>`
|
const menuList = await query<any>(
|
||||||
|
`
|
||||||
WITH RECURSIVE v_menu(
|
WITH RECURSIVE v_menu(
|
||||||
LEVEL,
|
LEVEL,
|
||||||
MENU_TYPE,
|
MENU_TYPE,
|
||||||
|
|
@ -310,12 +312,14 @@ export class AdminService {
|
||||||
FROM v_menu A
|
FROM v_menu A
|
||||||
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
LEFT JOIN COMPANY_MNG CM ON A.COMPANY_CODE = CM.COMPANY_CODE
|
||||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME ON A.LANG_KEY = MLKM_NAME.lang_key
|
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME ON A.LANG_KEY = MLKM_NAME.lang_key
|
||||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME ON MLKM_NAME.key_id = MLT_NAME.key_id AND MLT_NAME.lang_code = ${userLang}
|
LEFT JOIN MULTI_LANG_TEXT MLT_NAME ON MLKM_NAME.key_id = MLT_NAME.key_id AND MLT_NAME.lang_code = $1
|
||||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC ON A.LANG_KEY_DESC = MLKM_DESC.lang_key
|
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC ON A.LANG_KEY_DESC = MLKM_DESC.lang_key
|
||||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC ON MLKM_DESC.key_id = MLT_DESC.key_id AND MLT_DESC.lang_code = ${userLang}
|
LEFT JOIN MULTI_LANG_TEXT MLT_DESC ON MLKM_DESC.key_id = MLT_DESC.key_id AND MLT_DESC.lang_code = $1
|
||||||
WHERE 1 = 1
|
WHERE 1 = 1
|
||||||
ORDER BY PATH, SEQ
|
ORDER BY PATH, SEQ
|
||||||
`;
|
`,
|
||||||
|
[userLang]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
logger.info(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||||
if (menuList.length > 0) {
|
if (menuList.length > 0) {
|
||||||
|
|
@ -336,32 +340,31 @@ export class AdminService {
|
||||||
try {
|
try {
|
||||||
logger.info(`AdminService.getMenuInfo 시작 - menuId: ${menuId}`);
|
logger.info(`AdminService.getMenuInfo 시작 - menuId: ${menuId}`);
|
||||||
|
|
||||||
// Prisma ORM을 사용한 메뉴 정보 조회 (회사 정보 포함)
|
// Raw Query를 사용한 메뉴 정보 조회 (회사 정보 포함)
|
||||||
const menuInfo = await prisma.menu_info.findUnique({
|
const menuResult = await query<any>(
|
||||||
where: {
|
`SELECT
|
||||||
objid: Number(menuId),
|
m.*,
|
||||||
},
|
c.company_name
|
||||||
include: {
|
FROM menu_info m
|
||||||
company: {
|
LEFT JOIN company_mng c ON m.company_code = c.company_code
|
||||||
select: {
|
WHERE m.objid = $1::numeric`,
|
||||||
company_name: true,
|
[menuId]
|
||||||
},
|
);
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!menuInfo) {
|
if (!menuResult || menuResult.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuInfo = menuResult[0];
|
||||||
|
|
||||||
// 응답 형식 조정 (기존 형식과 호환성 유지)
|
// 응답 형식 조정 (기존 형식과 호환성 유지)
|
||||||
const result = {
|
const result = {
|
||||||
...menuInfo,
|
...menuInfo,
|
||||||
objid: menuInfo.objid.toString(), // BigInt를 문자열로 변환
|
objid: menuInfo.objid?.toString(),
|
||||||
menu_type: menuInfo.menu_type?.toString(),
|
menu_type: menuInfo.menu_type?.toString(),
|
||||||
parent_obj_id: menuInfo.parent_obj_id?.toString(),
|
parent_obj_id: menuInfo.parent_obj_id?.toString(),
|
||||||
seq: menuInfo.seq?.toString(),
|
seq: menuInfo.seq?.toString(),
|
||||||
company_name: menuInfo.company?.company_name || "미지정",
|
company_name: menuInfo.company_name || "미지정",
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info("메뉴 정보 조회 결과:", result);
|
logger.info("메뉴 정보 조회 결과:", result);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// 인증 서비스
|
// 인증 서비스
|
||||||
// 기존 Java LoginService를 Node.js로 포팅
|
// 기존 Java LoginService를 Node.js로 포팅
|
||||||
|
// ✅ Prisma → Raw Query 전환 완료 (Phase 1.5)
|
||||||
|
|
||||||
import prisma from "../config/database";
|
import { query } from "../database/db";
|
||||||
import { JwtUtils } from "../utils/jwtUtils";
|
import { JwtUtils } from "../utils/jwtUtils";
|
||||||
import { EncryptUtil } from "../utils/encryptUtil";
|
import { EncryptUtil } from "../utils/encryptUtil";
|
||||||
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
import { PersonBean, LoginResult, LoginLogData } from "../types/auth";
|
||||||
|
|
@ -17,15 +18,13 @@ export class AuthService {
|
||||||
password: string
|
password: string
|
||||||
): Promise<LoginResult> {
|
): Promise<LoginResult> {
|
||||||
try {
|
try {
|
||||||
// 사용자 비밀번호 조회 (기존 login.getUserPassword 쿼리 포팅)
|
// 사용자 비밀번호 조회 (Raw Query 전환)
|
||||||
const userInfo = await prisma.user_info.findUnique({
|
const result = await query<{ user_password: string }>(
|
||||||
where: {
|
"SELECT user_password FROM user_info WHERE user_id = $1",
|
||||||
user_id: userId,
|
[userId]
|
||||||
},
|
);
|
||||||
select: {
|
|
||||||
user_password: true,
|
const userInfo = result.length > 0 ? result[0] : null;
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (userInfo && userInfo.user_password) {
|
if (userInfo && userInfo.user_password) {
|
||||||
const dbPassword = userInfo.user_password;
|
const dbPassword = userInfo.user_password;
|
||||||
|
|
@ -78,32 +77,26 @@ export class AuthService {
|
||||||
*/
|
*/
|
||||||
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
static async insertLoginAccessLog(logData: LoginLogData): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 기존 login.insertLoginAccessLog 쿼리 포팅
|
// 로그인 로그 기록 (Raw Query 전환)
|
||||||
await prisma.$executeRaw`
|
await query(
|
||||||
INSERT INTO LOGIN_ACCESS_LOG(
|
`INSERT INTO LOGIN_ACCESS_LOG(
|
||||||
LOG_TIME,
|
LOG_TIME, SYSTEM_NAME, USER_ID, LOGIN_RESULT, ERROR_MESSAGE,
|
||||||
SYSTEM_NAME,
|
REMOTE_ADDR, RECPTN_DT, RECPTN_RSLT_DTL, RECPTN_RSLT, RECPTN_RSLT_CD
|
||||||
USER_ID,
|
|
||||||
LOGIN_RESULT,
|
|
||||||
ERROR_MESSAGE,
|
|
||||||
REMOTE_ADDR,
|
|
||||||
RECPTN_DT,
|
|
||||||
RECPTN_RSLT_DTL,
|
|
||||||
RECPTN_RSLT,
|
|
||||||
RECPTN_RSLT_CD
|
|
||||||
) VALUES (
|
) VALUES (
|
||||||
now(),
|
now(), $1, UPPER($2), $3, $4, $5, $6, $7, $8, $9
|
||||||
${logData.systemName},
|
)`,
|
||||||
UPPER(${logData.userId}),
|
[
|
||||||
${logData.loginResult},
|
logData.systemName,
|
||||||
${logData.errorMessage || null},
|
logData.userId,
|
||||||
${logData.remoteAddr},
|
logData.loginResult,
|
||||||
${logData.recptnDt || null},
|
logData.errorMessage || null,
|
||||||
${logData.recptnRsltDtl || null},
|
logData.remoteAddr,
|
||||||
${logData.recptnRslt || null},
|
logData.recptnDt || null,
|
||||||
${logData.recptnRsltCd || null}
|
logData.recptnRsltDtl || null,
|
||||||
)
|
logData.recptnRslt || null,
|
||||||
`;
|
logData.recptnRsltCd || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||||
|
|
@ -122,66 +115,61 @@ export class AuthService {
|
||||||
*/
|
*/
|
||||||
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
static async getUserInfo(userId: string): Promise<PersonBean | null> {
|
||||||
try {
|
try {
|
||||||
// 기존 login.getUserInfo 쿼리 포팅
|
// 1. 사용자 기본 정보 조회 (Raw Query 전환)
|
||||||
const userInfo = await prisma.user_info.findUnique({
|
const userResult = await query<{
|
||||||
where: {
|
sabun: string | null;
|
||||||
user_id: userId,
|
user_id: string;
|
||||||
},
|
user_name: string;
|
||||||
select: {
|
user_name_eng: string | null;
|
||||||
sabun: true,
|
user_name_cn: string | null;
|
||||||
user_id: true,
|
dept_code: string | null;
|
||||||
user_name: true,
|
dept_name: string | null;
|
||||||
user_name_eng: true,
|
position_code: string | null;
|
||||||
user_name_cn: true,
|
position_name: string | null;
|
||||||
dept_code: true,
|
email: string | null;
|
||||||
dept_name: true,
|
tel: string | null;
|
||||||
position_code: true,
|
cell_phone: string | null;
|
||||||
position_name: true,
|
user_type: string | null;
|
||||||
email: true,
|
user_type_name: string | null;
|
||||||
tel: true,
|
partner_objid: string | null;
|
||||||
cell_phone: true,
|
company_code: string | null;
|
||||||
user_type: true,
|
locale: string | null;
|
||||||
user_type_name: true,
|
photo: Buffer | null;
|
||||||
partner_objid: true,
|
}>(
|
||||||
company_code: true,
|
`SELECT
|
||||||
locale: true,
|
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||||
photo: true,
|
dept_code, dept_name, position_code, position_name,
|
||||||
},
|
email, tel, cell_phone, user_type, user_type_name,
|
||||||
});
|
partner_objid, company_code, locale, photo
|
||||||
|
FROM user_info
|
||||||
|
WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userInfo = userResult.length > 0 ? userResult[0] : null;
|
||||||
|
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 권한 정보 조회 (Prisma ORM 사용)
|
// 2. 권한 정보 조회 (Raw Query 전환 - JOIN으로 최적화)
|
||||||
const authInfo = await prisma.authority_sub_user.findMany({
|
const authResult = await query<{ auth_name: string }>(
|
||||||
where: {
|
`SELECT am.auth_name
|
||||||
user_id: userId,
|
FROM authority_sub_user asu
|
||||||
},
|
INNER JOIN authority_master am ON asu.master_objid = am.objid
|
||||||
include: {
|
WHERE asu.user_id = $1`,
|
||||||
authority_master: {
|
[userId]
|
||||||
select: {
|
);
|
||||||
auth_name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 권한명들을 쉼표로 연결
|
// 권한명들을 쉼표로 연결
|
||||||
const authNames = authInfo
|
const authNames = authResult.map((row) => row.auth_name).join(",");
|
||||||
.filter((auth: any) => auth.authority_master?.auth_name)
|
|
||||||
.map((auth: any) => auth.authority_master!.auth_name!)
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
// 회사 정보 조회 (Prisma ORM 사용으로 변경)
|
// 3. 회사 정보 조회 (Raw Query 전환)
|
||||||
const companyInfo = await prisma.company_mng.findFirst({
|
// Note: 현재 회사 정보는 PersonBean에 직접 사용되지 않지만 향후 확장을 위해 유지
|
||||||
where: {
|
const companyResult = await query<{ company_name: string }>(
|
||||||
company_code: userInfo.company_code || "ILSHIN",
|
"SELECT company_name FROM company_mng WHERE company_code = $1",
|
||||||
},
|
[userInfo.company_code || "ILSHIN"]
|
||||||
select: {
|
);
|
||||||
company_name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// DB에서 조회한 원본 사용자 정보 상세 로그
|
// DB에서 조회한 원본 사용자 정보 상세 로그
|
||||||
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
//console.log("🔍 AuthService - DB 원본 사용자 정보:", {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// 배치 실행 로그 서비스
|
// 배치 실행 로그 서비스
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import prisma from "../config/database";
|
import { query, queryOne } from "../database/db";
|
||||||
import {
|
import {
|
||||||
BatchExecutionLog,
|
BatchExecutionLog,
|
||||||
CreateBatchExecutionLogRequest,
|
CreateBatchExecutionLogRequest,
|
||||||
UpdateBatchExecutionLogRequest,
|
UpdateBatchExecutionLogRequest,
|
||||||
BatchExecutionLogFilter,
|
BatchExecutionLogFilter,
|
||||||
BatchExecutionLogWithConfig
|
BatchExecutionLogWithConfig,
|
||||||
} from "../types/batchExecutionLogTypes";
|
} from "../types/batchExecutionLogTypes";
|
||||||
import { ApiResponse } from "../types/batchTypes";
|
import { ApiResponse } from "../types/batchTypes";
|
||||||
|
|
||||||
|
|
@ -25,55 +25,75 @@ export class BatchExecutionLogService {
|
||||||
start_date,
|
start_date,
|
||||||
end_date,
|
end_date,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 50
|
limit = 50,
|
||||||
} = filter;
|
} = filter;
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const take = limit;
|
const take = limit;
|
||||||
|
|
||||||
// WHERE 조건 구성
|
// WHERE 조건 구성
|
||||||
const where: any = {};
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (batch_config_id) {
|
if (batch_config_id) {
|
||||||
where.batch_config_id = batch_config_id;
|
whereConditions.push(`bel.batch_config_id = $${paramIndex++}`);
|
||||||
|
params.push(batch_config_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (execution_status) {
|
if (execution_status) {
|
||||||
where.execution_status = execution_status;
|
whereConditions.push(`bel.execution_status = $${paramIndex++}`);
|
||||||
|
params.push(execution_status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start_date || end_date) {
|
|
||||||
where.start_time = {};
|
|
||||||
if (start_date) {
|
if (start_date) {
|
||||||
where.start_time.gte = start_date;
|
whereConditions.push(`bel.start_time >= $${paramIndex++}`);
|
||||||
}
|
params.push(start_date);
|
||||||
if (end_date) {
|
|
||||||
where.start_time.lte = end_date;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그 조회
|
if (end_date) {
|
||||||
const [logs, total] = await Promise.all([
|
whereConditions.push(`bel.start_time <= $${paramIndex++}`);
|
||||||
prisma.batch_execution_logs.findMany({
|
params.push(end_date);
|
||||||
where,
|
|
||||||
include: {
|
|
||||||
batch_config: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
batch_name: true,
|
|
||||||
description: true,
|
|
||||||
cron_schedule: true,
|
|
||||||
is_active: true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
const whereClause =
|
||||||
orderBy: { start_time: 'desc' },
|
whereConditions.length > 0
|
||||||
skip,
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
take
|
: "";
|
||||||
}),
|
|
||||||
prisma.batch_execution_logs.count({ where })
|
// 로그 조회 (batch_config 정보 포함)
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
bel.*,
|
||||||
|
json_build_object(
|
||||||
|
'id', bc.id,
|
||||||
|
'batch_name', bc.batch_name,
|
||||||
|
'description', bc.description,
|
||||||
|
'cron_schedule', bc.cron_schedule,
|
||||||
|
'is_active', bc.is_active
|
||||||
|
) as batch_config
|
||||||
|
FROM batch_execution_logs bel
|
||||||
|
LEFT JOIN batch_configs bc ON bel.batch_config_id = bc.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY bel.start_time DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const countSql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM batch_execution_logs bel
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
params.push(take, skip);
|
||||||
|
|
||||||
|
const [logs, countResult] = await Promise.all([
|
||||||
|
query<any>(sql, params),
|
||||||
|
query<{ count: number }>(countSql, params.slice(0, -2)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult[0]?.count?.toString() || "0", 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: logs as BatchExecutionLogWithConfig[],
|
data: logs as BatchExecutionLogWithConfig[],
|
||||||
|
|
@ -81,15 +101,15 @@ export class BatchExecutionLogService {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit)
|
totalPages: Math.ceil(total / limit),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 로그 조회 실패:", error);
|
console.error("배치 실행 로그 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
message: "배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,34 +121,40 @@ export class BatchExecutionLogService {
|
||||||
data: CreateBatchExecutionLogRequest
|
data: CreateBatchExecutionLogRequest
|
||||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||||
try {
|
try {
|
||||||
const log = await prisma.batch_execution_logs.create({
|
const log = await queryOne<BatchExecutionLog>(
|
||||||
data: {
|
`INSERT INTO batch_execution_logs (
|
||||||
batch_config_id: data.batch_config_id,
|
batch_config_id, execution_status, start_time, end_time,
|
||||||
execution_status: data.execution_status,
|
duration_ms, total_records, success_records, failed_records,
|
||||||
start_time: data.start_time || new Date(),
|
error_message, error_details, server_name, process_id
|
||||||
end_time: data.end_time,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
duration_ms: data.duration_ms,
|
RETURNING *`,
|
||||||
total_records: data.total_records || 0,
|
[
|
||||||
success_records: data.success_records || 0,
|
data.batch_config_id,
|
||||||
failed_records: data.failed_records || 0,
|
data.execution_status,
|
||||||
error_message: data.error_message,
|
data.start_time || new Date(),
|
||||||
error_details: data.error_details,
|
data.end_time,
|
||||||
server_name: data.server_name || process.env.HOSTNAME || 'unknown',
|
data.duration_ms,
|
||||||
process_id: data.process_id || process.pid?.toString()
|
data.total_records || 0,
|
||||||
}
|
data.success_records || 0,
|
||||||
});
|
data.failed_records || 0,
|
||||||
|
data.error_message,
|
||||||
|
data.error_details,
|
||||||
|
data.server_name || process.env.HOSTNAME || "unknown",
|
||||||
|
data.process_id || process.pid?.toString(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: log as BatchExecutionLog,
|
data: log as BatchExecutionLog,
|
||||||
message: "배치 실행 로그가 생성되었습니다."
|
message: "배치 실행 로그가 생성되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 로그 생성 실패:", error);
|
console.error("배치 실행 로그 생성 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
message: "배치 실행 로그 생성 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,31 +167,65 @@ export class BatchExecutionLogService {
|
||||||
data: UpdateBatchExecutionLogRequest
|
data: UpdateBatchExecutionLogRequest
|
||||||
): Promise<ApiResponse<BatchExecutionLog>> {
|
): Promise<ApiResponse<BatchExecutionLog>> {
|
||||||
try {
|
try {
|
||||||
const log = await prisma.batch_execution_logs.update({
|
// 동적 UPDATE 쿼리 생성
|
||||||
where: { id },
|
const updates: string[] = [];
|
||||||
data: {
|
const params: any[] = [];
|
||||||
execution_status: data.execution_status,
|
let paramIndex = 1;
|
||||||
end_time: data.end_time,
|
|
||||||
duration_ms: data.duration_ms,
|
if (data.execution_status !== undefined) {
|
||||||
total_records: data.total_records,
|
updates.push(`execution_status = $${paramIndex++}`);
|
||||||
success_records: data.success_records,
|
params.push(data.execution_status);
|
||||||
failed_records: data.failed_records,
|
|
||||||
error_message: data.error_message,
|
|
||||||
error_details: data.error_details
|
|
||||||
}
|
}
|
||||||
});
|
if (data.end_time !== undefined) {
|
||||||
|
updates.push(`end_time = $${paramIndex++}`);
|
||||||
|
params.push(data.end_time);
|
||||||
|
}
|
||||||
|
if (data.duration_ms !== undefined) {
|
||||||
|
updates.push(`duration_ms = $${paramIndex++}`);
|
||||||
|
params.push(data.duration_ms);
|
||||||
|
}
|
||||||
|
if (data.total_records !== undefined) {
|
||||||
|
updates.push(`total_records = $${paramIndex++}`);
|
||||||
|
params.push(data.total_records);
|
||||||
|
}
|
||||||
|
if (data.success_records !== undefined) {
|
||||||
|
updates.push(`success_records = $${paramIndex++}`);
|
||||||
|
params.push(data.success_records);
|
||||||
|
}
|
||||||
|
if (data.failed_records !== undefined) {
|
||||||
|
updates.push(`failed_records = $${paramIndex++}`);
|
||||||
|
params.push(data.failed_records);
|
||||||
|
}
|
||||||
|
if (data.error_message !== undefined) {
|
||||||
|
updates.push(`error_message = $${paramIndex++}`);
|
||||||
|
params.push(data.error_message);
|
||||||
|
}
|
||||||
|
if (data.error_details !== undefined) {
|
||||||
|
updates.push(`error_details = $${paramIndex++}`);
|
||||||
|
params.push(data.error_details);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const log = await queryOne<BatchExecutionLog>(
|
||||||
|
`UPDATE batch_execution_logs
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: log as BatchExecutionLog,
|
data: log as BatchExecutionLog,
|
||||||
message: "배치 실행 로그가 업데이트되었습니다."
|
message: "배치 실행 로그가 업데이트되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 로그 업데이트 실패:", error);
|
console.error("배치 실행 로그 업데이트 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
message: "배치 실행 로그 업데이트 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,20 +235,18 @@ export class BatchExecutionLogService {
|
||||||
*/
|
*/
|
||||||
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
|
static async deleteExecutionLog(id: number): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
await prisma.batch_execution_logs.delete({
|
await query(`DELETE FROM batch_execution_logs WHERE id = $1`, [id]);
|
||||||
where: { id }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "배치 실행 로그가 삭제되었습니다."
|
message: "배치 실행 로그가 삭제되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 로그 삭제 실패:", error);
|
console.error("배치 실행 로그 삭제 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
message: "배치 실행 로그 삭제 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,21 +258,24 @@ export class BatchExecutionLogService {
|
||||||
batchConfigId: number
|
batchConfigId: number
|
||||||
): Promise<ApiResponse<BatchExecutionLog | null>> {
|
): Promise<ApiResponse<BatchExecutionLog | null>> {
|
||||||
try {
|
try {
|
||||||
const log = await prisma.batch_execution_logs.findFirst({
|
const log = await queryOne<BatchExecutionLog>(
|
||||||
where: { batch_config_id: batchConfigId },
|
`SELECT * FROM batch_execution_logs
|
||||||
orderBy: { start_time: 'desc' }
|
WHERE batch_config_id = $1
|
||||||
});
|
ORDER BY start_time DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[batchConfigId]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: log as BatchExecutionLog | null
|
data: log || null,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("최신 배치 실행 로그 조회 실패:", error);
|
console.error("최신 배치 실행 로그 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
message: "최신 배치 실행 로그 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -226,50 +287,71 @@ export class BatchExecutionLogService {
|
||||||
batchConfigId?: number,
|
batchConfigId?: number,
|
||||||
startDate?: Date,
|
startDate?: Date,
|
||||||
endDate?: Date
|
endDate?: Date
|
||||||
): Promise<ApiResponse<{
|
): Promise<
|
||||||
|
ApiResponse<{
|
||||||
total_executions: number;
|
total_executions: number;
|
||||||
success_count: number;
|
success_count: number;
|
||||||
failed_count: number;
|
failed_count: number;
|
||||||
success_rate: number;
|
success_rate: number;
|
||||||
average_duration_ms: number;
|
average_duration_ms: number;
|
||||||
total_records_processed: number;
|
total_records_processed: number;
|
||||||
}>> {
|
}>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const where: any = {};
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (batchConfigId) {
|
if (batchConfigId) {
|
||||||
where.batch_config_id = batchConfigId;
|
whereConditions.push(`batch_config_id = $${paramIndex++}`);
|
||||||
|
params.push(batchConfigId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate || endDate) {
|
|
||||||
where.start_time = {};
|
|
||||||
if (startDate) {
|
if (startDate) {
|
||||||
where.start_time.gte = startDate;
|
whereConditions.push(`start_time >= $${paramIndex++}`);
|
||||||
}
|
params.push(startDate);
|
||||||
if (endDate) {
|
|
||||||
where.start_time.lte = endDate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await prisma.batch_execution_logs.findMany({
|
if (endDate) {
|
||||||
where,
|
whereConditions.push(`start_time <= $${paramIndex++}`);
|
||||||
select: {
|
params.push(endDate);
|
||||||
execution_status: true,
|
|
||||||
duration_ms: true,
|
|
||||||
total_records: true
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const logs = await query<{
|
||||||
|
execution_status: string;
|
||||||
|
duration_ms: number;
|
||||||
|
total_records: number;
|
||||||
|
}>(
|
||||||
|
`SELECT execution_status, duration_ms, total_records
|
||||||
|
FROM batch_execution_logs
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
const total_executions = logs.length;
|
const total_executions = logs.length;
|
||||||
const success_count = logs.filter((log: any) => log.execution_status === 'SUCCESS').length;
|
const success_count = logs.filter(
|
||||||
const failed_count = logs.filter((log: any) => log.execution_status === 'FAILED').length;
|
(log: any) => log.execution_status === "SUCCESS"
|
||||||
const success_rate = total_executions > 0 ? (success_count / total_executions) * 100 : 0;
|
).length;
|
||||||
|
const failed_count = logs.filter(
|
||||||
|
(log: any) => log.execution_status === "FAILED"
|
||||||
|
).length;
|
||||||
|
const success_rate =
|
||||||
|
total_executions > 0 ? (success_count / total_executions) * 100 : 0;
|
||||||
|
|
||||||
const validDurations = logs
|
const validDurations = logs
|
||||||
.filter((log: any) => log.duration_ms !== null)
|
.filter((log: any) => log.duration_ms !== null)
|
||||||
.map((log: any) => log.duration_ms!);
|
.map((log: any) => log.duration_ms!);
|
||||||
const average_duration_ms = validDurations.length > 0
|
const average_duration_ms =
|
||||||
? validDurations.reduce((sum: number, duration: number) => sum + duration, 0) / validDurations.length
|
validDurations.length > 0
|
||||||
|
? validDurations.reduce(
|
||||||
|
(sum: number, duration: number) => sum + duration,
|
||||||
|
0
|
||||||
|
) / validDurations.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const total_records_processed = logs
|
const total_records_processed = logs
|
||||||
|
|
@ -284,15 +366,15 @@ export class BatchExecutionLogService {
|
||||||
failed_count,
|
failed_count,
|
||||||
success_rate,
|
success_rate,
|
||||||
average_duration_ms,
|
average_duration_ms,
|
||||||
total_records_processed
|
total_records_processed,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치 실행 통계 조회 실패:", error);
|
console.error("배치 실행 통계 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
message: "배치 실행 통계 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,13 +1,13 @@
|
||||||
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
|
// 배치관리 전용 서비스 (기존 소스와 완전 분리)
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import prisma from "../config/database";
|
import { query, queryOne } from "../database/db";
|
||||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
|
||||||
|
|
||||||
// 배치관리 전용 타입 정의
|
// 배치관리 전용 타입 정의
|
||||||
export interface BatchConnectionInfo {
|
export interface BatchConnectionInfo {
|
||||||
type: 'internal' | 'external';
|
type: "internal" | "external";
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
db_type?: string;
|
db_type?: string;
|
||||||
|
|
@ -37,50 +37,54 @@ export class BatchManagementService {
|
||||||
/**
|
/**
|
||||||
* 배치관리용 연결 목록 조회
|
* 배치관리용 연결 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getAvailableConnections(): Promise<BatchApiResponse<BatchConnectionInfo[]>> {
|
static async getAvailableConnections(): Promise<
|
||||||
|
BatchApiResponse<BatchConnectionInfo[]>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const connections: BatchConnectionInfo[] = [];
|
const connections: BatchConnectionInfo[] = [];
|
||||||
|
|
||||||
// 내부 DB 추가
|
// 내부 DB 추가
|
||||||
connections.push({
|
connections.push({
|
||||||
type: 'internal',
|
type: "internal",
|
||||||
name: '내부 데이터베이스 (PostgreSQL)',
|
name: "내부 데이터베이스 (PostgreSQL)",
|
||||||
db_type: 'postgresql'
|
db_type: "postgresql",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 활성화된 외부 DB 연결 조회
|
// 활성화된 외부 DB 연결 조회
|
||||||
const externalConnections = await prisma.external_db_connections.findMany({
|
const externalConnections = await query<{
|
||||||
where: { is_active: 'Y' },
|
id: number;
|
||||||
select: {
|
connection_name: string;
|
||||||
id: true,
|
db_type: string;
|
||||||
connection_name: true,
|
description: string;
|
||||||
db_type: true,
|
}>(
|
||||||
description: true
|
`SELECT id, connection_name, db_type, description
|
||||||
},
|
FROM external_db_connections
|
||||||
orderBy: { connection_name: 'asc' }
|
WHERE is_active = 'Y'
|
||||||
});
|
ORDER BY connection_name ASC`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// 외부 DB 연결 추가
|
// 외부 DB 연결 추가
|
||||||
externalConnections.forEach(conn => {
|
externalConnections.forEach((conn) => {
|
||||||
connections.push({
|
connections.push({
|
||||||
type: 'external',
|
type: "external",
|
||||||
id: conn.id,
|
id: conn.id,
|
||||||
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
name: `${conn.connection_name} (${conn.db_type?.toUpperCase()})`,
|
||||||
db_type: conn.db_type || undefined
|
db_type: conn.db_type || undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: connections,
|
data: connections,
|
||||||
message: `${connections.length}개의 연결을 조회했습니다.`
|
message: `${connections.length}개의 연결을 조회했습니다.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치관리 연결 목록 조회 실패:", error);
|
console.error("배치관리 연결 목록 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
message: "연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,27 +93,28 @@ export class BatchManagementService {
|
||||||
* 배치관리용 테이블 목록 조회
|
* 배치관리용 테이블 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getTablesFromConnection(
|
static async getTablesFromConnection(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: "internal" | "external",
|
||||||
connectionId?: number
|
connectionId?: number
|
||||||
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||||
try {
|
try {
|
||||||
let tables: BatchTableInfo[] = [];
|
let tables: BatchTableInfo[] = [];
|
||||||
|
|
||||||
if (connectionType === 'internal') {
|
if (connectionType === "internal") {
|
||||||
// 내부 DB 테이블 조회
|
// 내부 DB 테이블 조회
|
||||||
const result = await prisma.$queryRaw<Array<{ table_name: string }>>`
|
const result = await query<{ table_name: string }>(
|
||||||
SELECT table_name
|
`SELECT table_name
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_type = 'BASE TABLE'
|
AND table_type = 'BASE TABLE'
|
||||||
ORDER BY table_name
|
ORDER BY table_name`,
|
||||||
`;
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
tables = result.map(row => ({
|
tables = result.map((row) => ({
|
||||||
table_name: row.table_name,
|
table_name: row.table_name,
|
||||||
columns: []
|
columns: [],
|
||||||
}));
|
}));
|
||||||
} else if (connectionType === 'external' && connectionId) {
|
} else if (connectionType === "external" && connectionId) {
|
||||||
// 외부 DB 테이블 조회
|
// 외부 DB 테이블 조회
|
||||||
const tablesResult = await this.getExternalTables(connectionId);
|
const tablesResult = await this.getExternalTables(connectionId);
|
||||||
if (tablesResult.success && tablesResult.data) {
|
if (tablesResult.success && tablesResult.data) {
|
||||||
|
|
@ -120,14 +125,14 @@ export class BatchManagementService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: tables,
|
data: tables,
|
||||||
message: `${tables.length}개의 테이블을 조회했습니다.`
|
message: `${tables.length}개의 테이블을 조회했습니다.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("배치관리 테이블 목록 조회 실패:", error);
|
console.error("배치관리 테이블 목록 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +141,7 @@ export class BatchManagementService {
|
||||||
* 배치관리용 테이블 컬럼 정보 조회
|
* 배치관리용 테이블 컬럼 정보 조회
|
||||||
*/
|
*/
|
||||||
static async getTableColumns(
|
static async getTableColumns(
|
||||||
connectionType: 'internal' | 'external',
|
connectionType: "internal" | "external",
|
||||||
connectionId: number | undefined,
|
connectionId: number | undefined,
|
||||||
tableName: string
|
tableName: string
|
||||||
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||||
|
|
@ -144,49 +149,60 @@ export class BatchManagementService {
|
||||||
console.log(`[BatchManagementService] getTableColumns 호출:`, {
|
console.log(`[BatchManagementService] getTableColumns 호출:`, {
|
||||||
connectionType,
|
connectionType,
|
||||||
connectionId,
|
connectionId,
|
||||||
tableName
|
tableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
let columns: BatchColumnInfo[] = [];
|
let columns: BatchColumnInfo[] = [];
|
||||||
|
|
||||||
if (connectionType === 'internal') {
|
if (connectionType === "internal") {
|
||||||
// 내부 DB 컬럼 조회
|
// 내부 DB 컬럼 조회
|
||||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`);
|
console.log(
|
||||||
|
`[BatchManagementService] 내부 DB 컬럼 조회 시작: ${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
const result = await prisma.$queryRaw<Array<{
|
const result = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
data_type: string;
|
data_type: string;
|
||||||
is_nullable: string;
|
is_nullable: string;
|
||||||
column_default: string | null
|
column_default: string | null;
|
||||||
}>>`
|
}>(
|
||||||
SELECT
|
`SELECT
|
||||||
column_name,
|
column_name,
|
||||||
data_type,
|
data_type,
|
||||||
is_nullable,
|
is_nullable,
|
||||||
column_default
|
column_default
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = ${tableName}
|
AND table_name = $1
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position`,
|
||||||
`;
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 쿼리 결과:`, result);
|
console.log(`[BatchManagementService] 쿼리 결과:`, result);
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
console.log(`[BatchManagementService] 내부 DB 컬럼 조회 결과:`, result);
|
||||||
|
|
||||||
columns = result.map(row => ({
|
columns = result.map((row) => ({
|
||||||
column_name: row.column_name,
|
column_name: row.column_name,
|
||||||
data_type: row.data_type,
|
data_type: row.data_type,
|
||||||
is_nullable: row.is_nullable,
|
is_nullable: row.is_nullable,
|
||||||
column_default: row.column_default,
|
column_default: row.column_default,
|
||||||
}));
|
}));
|
||||||
} else if (connectionType === 'external' && connectionId) {
|
} else if (connectionType === "external" && connectionId) {
|
||||||
// 외부 DB 컬럼 조회
|
// 외부 DB 컬럼 조회
|
||||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`);
|
console.log(
|
||||||
|
`[BatchManagementService] 외부 DB 컬럼 조회 시작: connectionId=${connectionId}, tableName=${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
const columnsResult = await this.getExternalTableColumns(connectionId, tableName);
|
const columnsResult = await this.getExternalTableColumns(
|
||||||
|
connectionId,
|
||||||
|
tableName
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 외부 DB 컬럼 조회 결과:`, columnsResult);
|
console.log(
|
||||||
|
`[BatchManagementService] 외부 DB 컬럼 조회 결과:`,
|
||||||
|
columnsResult
|
||||||
|
);
|
||||||
|
|
||||||
if (columnsResult.success && columnsResult.data) {
|
if (columnsResult.success && columnsResult.data) {
|
||||||
columns = columnsResult.data;
|
columns = columnsResult.data;
|
||||||
|
|
@ -197,14 +213,14 @@ export class BatchManagementService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: columns,
|
data: columns,
|
||||||
message: `${columns.length}개의 컬럼을 조회했습니다.`
|
message: `${columns.length}개의 컬럼을 조회했습니다.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
|
console.error("[BatchManagementService] 컬럼 정보 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,17 +228,20 @@ export class BatchManagementService {
|
||||||
/**
|
/**
|
||||||
* 외부 DB 테이블 목록 조회 (내부 구현)
|
* 외부 DB 테이블 목록 조회 (내부 구현)
|
||||||
*/
|
*/
|
||||||
private static async getExternalTables(connectionId: number): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
private static async getExternalTables(
|
||||||
|
connectionId: number
|
||||||
|
): Promise<BatchApiResponse<BatchTableInfo[]>> {
|
||||||
try {
|
try {
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id: connectionId }
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다."
|
message: "연결 정보를 찾을 수 없습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +250,7 @@ export class BatchManagementService {
|
||||||
if (!decryptedPassword) {
|
if (!decryptedPassword) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "비밀번호 복호화에 실패했습니다."
|
message: "비밀번호 복호화에 실패했습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,26 +261,39 @@ export class BatchManagementService {
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
connectionTimeoutMillis:
|
||||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
connection.connection_timeout != null
|
||||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
? connection.connection_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
queryTimeoutMillis:
|
||||||
|
connection.query_timeout != null
|
||||||
|
? connection.query_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
ssl:
|
||||||
|
connection.ssl_enabled === "Y"
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
// DatabaseConnectorFactory를 통한 테이블 목록 조회
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type,
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
const tables = await connector.getTables();
|
const tables = await connector.getTables();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "테이블 목록을 조회했습니다.",
|
message: "테이블 목록을 조회했습니다.",
|
||||||
data: tables
|
data: tables,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
console.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,20 +301,28 @@ export class BatchManagementService {
|
||||||
/**
|
/**
|
||||||
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
* 외부 DB 테이블 컬럼 정보 조회 (내부 구현)
|
||||||
*/
|
*/
|
||||||
private static async getExternalTableColumns(connectionId: number, tableName: string): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
private static async getExternalTableColumns(
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string
|
||||||
|
): Promise<BatchApiResponse<BatchColumnInfo[]>> {
|
||||||
try {
|
try {
|
||||||
console.log(`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`);
|
console.log(
|
||||||
|
`[BatchManagementService] getExternalTableColumns 호출: connectionId=${connectionId}, tableName=${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id: connectionId }
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
console.log(`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`);
|
console.log(
|
||||||
|
`[BatchManagementService] 연결 정보를 찾을 수 없음: connectionId=${connectionId}`
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 정보를 찾을 수 없습니다."
|
message: "연결 정보를 찾을 수 없습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,7 +332,7 @@ export class BatchManagementService {
|
||||||
db_type: connection.db_type,
|
db_type: connection.db_type,
|
||||||
host: connection.host,
|
host: connection.host,
|
||||||
port: connection.port,
|
port: connection.port,
|
||||||
database_name: connection.database_name
|
database_name: connection.database_name,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
|
|
@ -305,24 +345,44 @@ export class BatchManagementService {
|
||||||
database: connection.database_name,
|
database: connection.database_name,
|
||||||
user: connection.username,
|
user: connection.username,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined,
|
connectionTimeoutMillis:
|
||||||
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined,
|
connection.connection_timeout != null
|
||||||
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false
|
? connection.connection_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
queryTimeoutMillis:
|
||||||
|
connection.query_timeout != null
|
||||||
|
? connection.query_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
ssl:
|
||||||
|
connection.ssl_enabled === "Y"
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`);
|
console.log(
|
||||||
|
`[BatchManagementService] 커넥터 생성 시작: db_type=${connection.db_type}`
|
||||||
|
);
|
||||||
|
|
||||||
// 데이터베이스 타입에 따른 커넥터 생성
|
// 데이터베이스 타입에 따른 커넥터 생성
|
||||||
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, connectionId);
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type,
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`);
|
console.log(
|
||||||
|
`[BatchManagementService] 커넥터 생성 완료, 컬럼 조회 시작: tableName=${tableName}`
|
||||||
|
);
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
// 컬럼 정보 조회
|
||||||
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
|
console.log(`[BatchManagementService] connector.getColumns 호출 전`);
|
||||||
const columns = await connector.getColumns(tableName);
|
const columns = await connector.getColumns(tableName);
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
|
console.log(`[BatchManagementService] 원본 컬럼 조회 결과:`, columns);
|
||||||
console.log(`[BatchManagementService] 원본 컬럼 개수:`, columns ? columns.length : 'null/undefined');
|
console.log(
|
||||||
|
`[BatchManagementService] 원본 컬럼 개수:`,
|
||||||
|
columns ? columns.length : "null/undefined"
|
||||||
|
);
|
||||||
|
|
||||||
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
// 각 데이터베이스 커넥터의 반환 구조가 다르므로 통일된 구조로 변환
|
||||||
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
|
const standardizedColumns: BatchColumnInfo[] = columns.map((col: any) => {
|
||||||
|
|
@ -333,10 +393,13 @@ export class BatchManagementService {
|
||||||
const result = {
|
const result = {
|
||||||
column_name: col.name,
|
column_name: col.name,
|
||||||
data_type: col.dataType,
|
data_type: col.dataType,
|
||||||
is_nullable: col.isNullable ? 'YES' : 'NO',
|
is_nullable: col.isNullable ? "YES" : "NO",
|
||||||
column_default: col.defaultValue || null,
|
column_default: col.defaultValue || null,
|
||||||
};
|
};
|
||||||
console.log(`[BatchManagementService] MySQL/MariaDB 구조로 변환:`, result);
|
console.log(
|
||||||
|
`[BatchManagementService] MySQL/MariaDB 구조로 변환:`,
|
||||||
|
result
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
// PostgreSQL/Oracle/MSSQL/MariaDB 구조: {column_name, data_type, is_nullable, column_default}
|
||||||
|
|
@ -344,7 +407,10 @@ export class BatchManagementService {
|
||||||
const result = {
|
const result = {
|
||||||
column_name: col.column_name || col.COLUMN_NAME,
|
column_name: col.column_name || col.COLUMN_NAME,
|
||||||
data_type: col.data_type || col.DATA_TYPE,
|
data_type: col.data_type || col.DATA_TYPE,
|
||||||
is_nullable: col.is_nullable || col.IS_NULLABLE || (col.nullable === 'Y' ? 'YES' : 'NO'),
|
is_nullable:
|
||||||
|
col.is_nullable ||
|
||||||
|
col.IS_NULLABLE ||
|
||||||
|
(col.nullable === "Y" ? "YES" : "NO"),
|
||||||
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
column_default: col.column_default || col.COLUMN_DEFAULT || null,
|
||||||
};
|
};
|
||||||
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
|
console.log(`[BatchManagementService] 표준 구조로 변환:`, result);
|
||||||
|
|
@ -352,22 +418,30 @@ export class BatchManagementService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[BatchManagementService] 표준화된 컬럼 목록:`, standardizedColumns);
|
console.log(
|
||||||
|
`[BatchManagementService] 표준화된 컬럼 목록:`,
|
||||||
|
standardizedColumns
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: standardizedColumns,
|
data: standardizedColumns,
|
||||||
message: "컬럼 정보를 조회했습니다."
|
message: "컬럼 정보를 조회했습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:", error);
|
console.error(
|
||||||
console.error("[BatchManagementService] 오류 스택:", error instanceof Error ? error.stack : 'No stack trace');
|
"[BatchManagementService] 외부 DB 컬럼 정보 조회 오류:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
console.error(
|
||||||
|
"[BatchManagementService] 오류 스택:",
|
||||||
|
error instanceof Error ? error.stack : "No stack trace"
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
// 배치 스케줄러 서비스
|
// 배치 스케줄러 서비스
|
||||||
// 작성일: 2024-12-24
|
// 작성일: 2024-12-24
|
||||||
|
|
||||||
import * as cron from 'node-cron';
|
import * as cron from "node-cron";
|
||||||
import prisma from '../config/database';
|
import { query, queryOne } from "../database/db";
|
||||||
import { BatchService } from './batchService';
|
import { BatchService } from "./batchService";
|
||||||
import { BatchExecutionLogService } from './batchExecutionLogService';
|
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export class BatchSchedulerService {
|
export class BatchSchedulerService {
|
||||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||||
|
|
@ -17,7 +17,7 @@ export class BatchSchedulerService {
|
||||||
*/
|
*/
|
||||||
static async initialize() {
|
static async initialize() {
|
||||||
try {
|
try {
|
||||||
logger.info('배치 스케줄러 초기화 시작...');
|
logger.info("배치 스케줄러 초기화 시작...");
|
||||||
|
|
||||||
// 기존 모든 스케줄 정리 (중복 방지)
|
// 기존 모든 스케줄 정리 (중복 방지)
|
||||||
this.clearAllSchedules();
|
this.clearAllSchedules();
|
||||||
|
|
@ -26,9 +26,9 @@ export class BatchSchedulerService {
|
||||||
await this.loadActiveBatchConfigs();
|
await this.loadActiveBatchConfigs();
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
logger.info('배치 스케줄러 초기화 완료');
|
logger.info("배치 스케줄러 초기화 완료");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('배치 스케줄러 초기화 실패:', error);
|
logger.error("배치 스케줄러 초기화 실패:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ export class BatchSchedulerService {
|
||||||
|
|
||||||
this.scheduledTasks.clear();
|
this.scheduledTasks.clear();
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
logger.info('모든 스케줄 정리 완료');
|
logger.info("모든 스케줄 정리 완료");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,14 +59,43 @@ export class BatchSchedulerService {
|
||||||
*/
|
*/
|
||||||
private static async loadActiveBatchConfigs() {
|
private static async loadActiveBatchConfigs() {
|
||||||
try {
|
try {
|
||||||
const activeConfigs = await prisma.batch_configs.findMany({
|
const activeConfigs = await query<any>(
|
||||||
where: {
|
`SELECT
|
||||||
is_active: 'Y'
|
bc.*,
|
||||||
},
|
json_agg(
|
||||||
include: {
|
json_build_object(
|
||||||
batch_mappings: true
|
'id', bm.id,
|
||||||
}
|
'batch_config_id', bm.batch_config_id,
|
||||||
});
|
'from_connection_type', bm.from_connection_type,
|
||||||
|
'from_connection_id', bm.from_connection_id,
|
||||||
|
'from_table_name', bm.from_table_name,
|
||||||
|
'from_column_name', bm.from_column_name,
|
||||||
|
'from_column_type', bm.from_column_type,
|
||||||
|
'to_connection_type', bm.to_connection_type,
|
||||||
|
'to_connection_id', bm.to_connection_id,
|
||||||
|
'to_table_name', bm.to_table_name,
|
||||||
|
'to_column_name', bm.to_column_name,
|
||||||
|
'to_column_type', bm.to_column_type,
|
||||||
|
'mapping_order', bm.mapping_order,
|
||||||
|
'from_api_url', bm.from_api_url,
|
||||||
|
'from_api_key', bm.from_api_key,
|
||||||
|
'from_api_method', bm.from_api_method,
|
||||||
|
'from_api_param_type', bm.from_api_param_type,
|
||||||
|
'from_api_param_name', bm.from_api_param_name,
|
||||||
|
'from_api_param_value', bm.from_api_param_value,
|
||||||
|
'from_api_param_source', bm.from_api_param_source,
|
||||||
|
'to_api_url', bm.to_api_url,
|
||||||
|
'to_api_key', bm.to_api_key,
|
||||||
|
'to_api_method', bm.to_api_method,
|
||||||
|
'to_api_body', bm.to_api_body
|
||||||
|
)
|
||||||
|
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||||
|
FROM batch_configs bc
|
||||||
|
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||||
|
WHERE bc.is_active = 'Y'
|
||||||
|
GROUP BY bc.id`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||||
|
|
||||||
|
|
@ -74,7 +103,7 @@ export class BatchSchedulerService {
|
||||||
await this.scheduleBatchConfig(config);
|
await this.scheduleBatchConfig(config);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('활성화된 배치 설정 로드 실패:', error);
|
logger.error("활성화된 배치 설정 로드 실패:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +131,9 @@ export class BatchSchedulerService {
|
||||||
const task = cron.schedule(cron_schedule, async () => {
|
const task = cron.schedule(cron_schedule, async () => {
|
||||||
// 중복 실행 방지 체크
|
// 중복 실행 방지 체크
|
||||||
if (this.executingBatches.has(id)) {
|
if (this.executingBatches.has(id)) {
|
||||||
logger.warn(`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`);
|
logger.warn(
|
||||||
|
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,7 +154,9 @@ export class BatchSchedulerService {
|
||||||
task.start();
|
task.start();
|
||||||
|
|
||||||
this.scheduledTasks.set(id, task);
|
this.scheduledTasks.set(id, task);
|
||||||
logger.info(`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`);
|
logger.info(
|
||||||
|
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -147,16 +180,54 @@ export class BatchSchedulerService {
|
||||||
/**
|
/**
|
||||||
* 배치 설정 업데이트 시 스케줄 재등록
|
* 배치 설정 업데이트 시 스케줄 재등록
|
||||||
*/
|
*/
|
||||||
static async updateBatchSchedule(configId: number, executeImmediately: boolean = true) {
|
static async updateBatchSchedule(
|
||||||
|
configId: number,
|
||||||
|
executeImmediately: boolean = true
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
// 기존 스케줄 제거
|
// 기존 스케줄 제거
|
||||||
await this.unscheduleBatchConfig(configId);
|
await this.unscheduleBatchConfig(configId);
|
||||||
|
|
||||||
// 업데이트된 배치 설정 조회
|
// 업데이트된 배치 설정 조회
|
||||||
const config = await prisma.batch_configs.findUnique({
|
const configResult = await query<any>(
|
||||||
where: { id: configId },
|
`SELECT
|
||||||
include: { batch_mappings: true }
|
bc.*,
|
||||||
});
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', bm.id,
|
||||||
|
'batch_config_id', bm.batch_config_id,
|
||||||
|
'from_connection_type', bm.from_connection_type,
|
||||||
|
'from_connection_id', bm.from_connection_id,
|
||||||
|
'from_table_name', bm.from_table_name,
|
||||||
|
'from_column_name', bm.from_column_name,
|
||||||
|
'from_column_type', bm.from_column_type,
|
||||||
|
'to_connection_type', bm.to_connection_type,
|
||||||
|
'to_connection_id', bm.to_connection_id,
|
||||||
|
'to_table_name', bm.to_table_name,
|
||||||
|
'to_column_name', bm.to_column_name,
|
||||||
|
'to_column_type', bm.to_column_type,
|
||||||
|
'mapping_order', bm.mapping_order,
|
||||||
|
'from_api_url', bm.from_api_url,
|
||||||
|
'from_api_key', bm.from_api_key,
|
||||||
|
'from_api_method', bm.from_api_method,
|
||||||
|
'from_api_param_type', bm.from_api_param_type,
|
||||||
|
'from_api_param_name', bm.from_api_param_name,
|
||||||
|
'from_api_param_value', bm.from_api_param_value,
|
||||||
|
'from_api_param_source', bm.from_api_param_source,
|
||||||
|
'to_api_url', bm.to_api_url,
|
||||||
|
'to_api_key', bm.to_api_key,
|
||||||
|
'to_api_method', bm.to_api_method,
|
||||||
|
'to_api_body', bm.to_api_body
|
||||||
|
)
|
||||||
|
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||||
|
FROM batch_configs bc
|
||||||
|
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||||
|
WHERE bc.id = $1
|
||||||
|
GROUP BY bc.id`,
|
||||||
|
[configId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = configResult[0] || null;
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||||
|
|
@ -164,17 +235,23 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 활성화된 배치만 다시 스케줄 등록
|
// 활성화된 배치만 다시 스케줄 등록
|
||||||
if (config.is_active === 'Y') {
|
if (config.is_active === "Y") {
|
||||||
await this.scheduleBatchConfig(config);
|
await this.scheduleBatchConfig(config);
|
||||||
logger.info(`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`);
|
logger.info(
|
||||||
|
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
||||||
|
);
|
||||||
|
|
||||||
// 활성화 시 즉시 실행 (옵션)
|
// 활성화 시 즉시 실행 (옵션)
|
||||||
if (executeImmediately) {
|
if (executeImmediately) {
|
||||||
logger.info(`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`);
|
logger.info(
|
||||||
|
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
||||||
|
);
|
||||||
await this.executeBatchConfig(config);
|
await this.executeBatchConfig(config);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.info(`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`);
|
logger.info(
|
||||||
|
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||||
|
|
@ -192,21 +269,25 @@ export class BatchSchedulerService {
|
||||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
|
||||||
|
|
||||||
// 실행 로그 생성
|
// 실행 로그 생성
|
||||||
const executionLogResponse = await BatchExecutionLogService.createExecutionLog({
|
const executionLogResponse =
|
||||||
|
await BatchExecutionLogService.createExecutionLog({
|
||||||
batch_config_id: config.id,
|
batch_config_id: config.id,
|
||||||
execution_status: 'RUNNING',
|
execution_status: "RUNNING",
|
||||||
start_time: startTime,
|
start_time: startTime,
|
||||||
total_records: 0,
|
total_records: 0,
|
||||||
success_records: 0,
|
success_records: 0,
|
||||||
failed_records: 0
|
failed_records: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!executionLogResponse.success || !executionLogResponse.data) {
|
if (!executionLogResponse.success || !executionLogResponse.data) {
|
||||||
logger.error(`배치 실행 로그 생성 실패: ${config.batch_name}`, executionLogResponse.message);
|
logger.error(
|
||||||
|
`배치 실행 로그 생성 실패: ${config.batch_name}`,
|
||||||
|
executionLogResponse.message
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
successRecords: 0,
|
successRecords: 0,
|
||||||
failedRecords: 1
|
failedRecords: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,30 +298,32 @@ export class BatchSchedulerService {
|
||||||
|
|
||||||
// 실행 로그 업데이트 (성공)
|
// 실행 로그 업데이트 (성공)
|
||||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: 'SUCCESS',
|
execution_status: "SUCCESS",
|
||||||
end_time: new Date(),
|
end_time: new Date(),
|
||||||
duration_ms: Date.now() - startTime.getTime(),
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
total_records: result.totalRecords,
|
total_records: result.totalRecords,
|
||||||
success_records: result.successRecords,
|
success_records: result.successRecords,
|
||||||
failed_records: result.failedRecords
|
failed_records: result.failedRecords,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`);
|
logger.info(
|
||||||
|
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
|
||||||
|
);
|
||||||
|
|
||||||
// 성공 결과 반환
|
// 성공 결과 반환
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||||
|
|
||||||
// 실행 로그 업데이트 (실패)
|
// 실행 로그 업데이트 (실패)
|
||||||
if (executionLog) {
|
if (executionLog) {
|
||||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||||
execution_status: 'FAILED',
|
execution_status: "FAILED",
|
||||||
end_time: new Date(),
|
end_time: new Date(),
|
||||||
duration_ms: Date.now() - startTime.getTime(),
|
duration_ms: Date.now() - startTime.getTime(),
|
||||||
error_message: error instanceof Error ? error.message : '알 수 없는 오류',
|
error_message:
|
||||||
error_details: error instanceof Error ? error.stack : String(error)
|
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
error_details: error instanceof Error ? error.stack : String(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,7 +331,7 @@ export class BatchSchedulerService {
|
||||||
return {
|
return {
|
||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
successRecords: 0,
|
successRecords: 0,
|
||||||
failedRecords: 1
|
failedRecords: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +353,7 @@ export class BatchSchedulerService {
|
||||||
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
const tableGroups = new Map<string, typeof config.batch_mappings>();
|
||||||
|
|
||||||
for (const mapping of config.batch_mappings) {
|
for (const mapping of config.batch_mappings) {
|
||||||
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || 'internal'}:${mapping.from_table_name}`;
|
const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`;
|
||||||
if (!tableGroups.has(key)) {
|
if (!tableGroups.has(key)) {
|
||||||
tableGroups.set(key, []);
|
tableGroups.set(key, []);
|
||||||
}
|
}
|
||||||
|
|
@ -281,20 +364,30 @@ export class BatchSchedulerService {
|
||||||
for (const [tableKey, mappings] of tableGroups) {
|
for (const [tableKey, mappings] of tableGroups) {
|
||||||
try {
|
try {
|
||||||
const firstMapping = mappings[0];
|
const firstMapping = mappings[0];
|
||||||
logger.info(`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`);
|
logger.info(
|
||||||
|
`테이블 처리 시작: ${tableKey} -> ${mappings.length}개 컬럼 매핑`
|
||||||
|
);
|
||||||
|
|
||||||
let fromData: any[] = [];
|
let fromData: any[] = [];
|
||||||
|
|
||||||
// FROM 데이터 조회 (DB 또는 REST API)
|
// FROM 데이터 조회 (DB 또는 REST API)
|
||||||
if (firstMapping.from_connection_type === 'restapi') {
|
if (firstMapping.from_connection_type === "restapi") {
|
||||||
// REST API에서 데이터 조회
|
// REST API에서 데이터 조회
|
||||||
logger.info(`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`);
|
logger.info(
|
||||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
`REST API에서 데이터 조회: ${firstMapping.from_api_url}${firstMapping.from_table_name}`
|
||||||
|
);
|
||||||
|
const { BatchExternalDbService } = await import(
|
||||||
|
"./batchExternalDbService"
|
||||||
|
);
|
||||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||||
firstMapping.from_api_url!,
|
firstMapping.from_api_url!,
|
||||||
firstMapping.from_api_key!,
|
firstMapping.from_api_key!,
|
||||||
firstMapping.from_table_name,
|
firstMapping.from_table_name,
|
||||||
firstMapping.from_api_method as 'GET' | 'POST' | 'PUT' | 'DELETE' || 'GET',
|
(firstMapping.from_api_method as
|
||||||
|
| "GET"
|
||||||
|
| "POST"
|
||||||
|
| "PUT"
|
||||||
|
| "DELETE") || "GET",
|
||||||
mappings.map((m: any) => m.from_column_name),
|
mappings.map((m: any) => m.from_column_name),
|
||||||
100, // limit
|
100, // limit
|
||||||
// 파라미터 정보 전달
|
// 파라미터 정보 전달
|
||||||
|
|
@ -315,7 +408,7 @@ export class BatchSchedulerService {
|
||||||
fromData = await BatchService.getDataFromTableWithColumns(
|
fromData = await BatchService.getDataFromTableWithColumns(
|
||||||
firstMapping.from_table_name,
|
firstMapping.from_table_name,
|
||||||
fromColumns,
|
fromColumns,
|
||||||
firstMapping.from_connection_type as 'internal' | 'external',
|
firstMapping.from_connection_type as "internal" | "external",
|
||||||
firstMapping.from_connection_id || undefined
|
firstMapping.from_connection_id || undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -323,13 +416,17 @@ export class BatchSchedulerService {
|
||||||
totalRecords += fromData.length;
|
totalRecords += fromData.length;
|
||||||
|
|
||||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||||
const mappedData = fromData.map(row => {
|
const mappedData = fromData.map((row) => {
|
||||||
const mappedRow: any = {};
|
const mappedRow: any = {};
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
// DB → REST API 배치인지 확인
|
// DB → REST API 배치인지 확인
|
||||||
if (firstMapping.to_connection_type === 'restapi' && mapping.to_api_body) {
|
if (
|
||||||
|
firstMapping.to_connection_type === "restapi" &&
|
||||||
|
mapping.to_api_body
|
||||||
|
) {
|
||||||
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
|
// DB → REST API: 원본 컬럼명을 키로 사용 (템플릿 처리용)
|
||||||
mappedRow[mapping.from_column_name] = row[mapping.from_column_name];
|
mappedRow[mapping.from_column_name] =
|
||||||
|
row[mapping.from_column_name];
|
||||||
} else {
|
} else {
|
||||||
// 기존 로직: to_column_name을 키로 사용
|
// 기존 로직: to_column_name을 키로 사용
|
||||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||||
|
|
@ -341,27 +438,37 @@ export class BatchSchedulerService {
|
||||||
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
// TO 테이블에 데이터 삽입 (DB 또는 REST API)
|
||||||
let insertResult: { successCount: number; failedCount: number };
|
let insertResult: { successCount: number; failedCount: number };
|
||||||
|
|
||||||
if (firstMapping.to_connection_type === 'restapi') {
|
if (firstMapping.to_connection_type === "restapi") {
|
||||||
// REST API로 데이터 전송
|
// REST API로 데이터 전송
|
||||||
logger.info(`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`);
|
logger.info(
|
||||||
const { BatchExternalDbService } = await import('./batchExternalDbService');
|
`REST API로 데이터 전송: ${firstMapping.to_api_url}${firstMapping.to_table_name}`
|
||||||
|
);
|
||||||
|
const { BatchExternalDbService } = await import(
|
||||||
|
"./batchExternalDbService"
|
||||||
|
);
|
||||||
|
|
||||||
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
// DB → REST API 배치인지 확인 (to_api_body가 있으면 템플릿 기반)
|
||||||
const hasTemplate = mappings.some((m: any) => m.to_api_body);
|
const hasTemplate = mappings.some((m: any) => m.to_api_body);
|
||||||
|
|
||||||
if (hasTemplate) {
|
if (hasTemplate) {
|
||||||
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
// 템플릿 기반 REST API 전송 (DB → REST API 배치)
|
||||||
const templateBody = firstMapping.to_api_body || '{}';
|
const templateBody = firstMapping.to_api_body || "{}";
|
||||||
logger.info(`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`);
|
logger.info(
|
||||||
|
`템플릿 기반 REST API 전송, Request Body 템플릿: ${templateBody}`
|
||||||
|
);
|
||||||
|
|
||||||
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
// URL 경로 컬럼 찾기 (PUT/DELETE용)
|
||||||
const urlPathColumn = mappings.find((m: any) => m.to_column_name === 'URL_PATH_PARAM')?.from_column_name;
|
const urlPathColumn = mappings.find(
|
||||||
|
(m: any) => m.to_column_name === "URL_PATH_PARAM"
|
||||||
|
)?.from_column_name;
|
||||||
|
|
||||||
const apiResult = await BatchExternalDbService.sendDataToRestApiWithTemplate(
|
const apiResult =
|
||||||
|
await BatchExternalDbService.sendDataToRestApiWithTemplate(
|
||||||
firstMapping.to_api_url!,
|
firstMapping.to_api_url!,
|
||||||
firstMapping.to_api_key!,
|
firstMapping.to_api_key!,
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
firstMapping.to_api_method as 'POST' | 'PUT' | 'DELETE' || 'POST',
|
(firstMapping.to_api_method as "POST" | "PUT" | "DELETE") ||
|
||||||
|
"POST",
|
||||||
templateBody,
|
templateBody,
|
||||||
mappedData,
|
mappedData,
|
||||||
urlPathColumn
|
urlPathColumn
|
||||||
|
|
@ -370,7 +477,9 @@ export class BatchSchedulerService {
|
||||||
if (apiResult.success && apiResult.data) {
|
if (apiResult.success && apiResult.data) {
|
||||||
insertResult = apiResult.data;
|
insertResult = apiResult.data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`);
|
throw new Error(
|
||||||
|
`템플릿 기반 REST API 데이터 전송 실패: ${apiResult.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기존 REST API 전송 (REST API → DB 배치)
|
// 기존 REST API 전송 (REST API → DB 배치)
|
||||||
|
|
@ -378,14 +487,16 @@ export class BatchSchedulerService {
|
||||||
firstMapping.to_api_url!,
|
firstMapping.to_api_url!,
|
||||||
firstMapping.to_api_key!,
|
firstMapping.to_api_key!,
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
firstMapping.to_api_method as 'POST' | 'PUT' || 'POST',
|
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
||||||
mappedData
|
mappedData
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apiResult.success && apiResult.data) {
|
if (apiResult.success && apiResult.data) {
|
||||||
insertResult = apiResult.data;
|
insertResult = apiResult.data;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`REST API 데이터 전송 실패: ${apiResult.message}`);
|
throw new Error(
|
||||||
|
`REST API 데이터 전송 실패: ${apiResult.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -393,7 +504,7 @@ export class BatchSchedulerService {
|
||||||
insertResult = await BatchService.insertDataToTable(
|
insertResult = await BatchService.insertDataToTable(
|
||||||
firstMapping.to_table_name,
|
firstMapping.to_table_name,
|
||||||
mappedData,
|
mappedData,
|
||||||
firstMapping.to_connection_type as 'internal' | 'external',
|
firstMapping.to_connection_type as "internal" | "external",
|
||||||
firstMapping.to_connection_id || undefined
|
firstMapping.to_connection_id || undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -401,7 +512,9 @@ export class BatchSchedulerService {
|
||||||
successRecords += insertResult.successCount;
|
successRecords += insertResult.successCount;
|
||||||
failedRecords += insertResult.failedCount;
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
logger.info(`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
logger.info(
|
||||||
|
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||||
failedRecords += 1;
|
failedRecords += 1;
|
||||||
|
|
@ -427,7 +540,9 @@ export class BatchSchedulerService {
|
||||||
|
|
||||||
for (const mapping of batch_mappings) {
|
for (const mapping of batch_mappings) {
|
||||||
try {
|
try {
|
||||||
logger.info(`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`);
|
logger.info(
|
||||||
|
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
|
||||||
|
);
|
||||||
|
|
||||||
// FROM 테이블에서 데이터 조회
|
// FROM 테이블에서 데이터 조회
|
||||||
const fromData = await this.getDataFromSource(mapping);
|
const fromData = await this.getDataFromSource(mapping);
|
||||||
|
|
@ -438,9 +553,14 @@ export class BatchSchedulerService {
|
||||||
successRecords += insertResult.successCount;
|
successRecords += insertResult.successCount;
|
||||||
failedRecords += insertResult.failedCount;
|
failedRecords += insertResult.failedCount;
|
||||||
|
|
||||||
logger.info(`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`);
|
logger.info(
|
||||||
|
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`, error);
|
logger.error(
|
||||||
|
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
failedRecords += 1;
|
failedRecords += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -453,19 +573,23 @@ export class BatchSchedulerService {
|
||||||
*/
|
*/
|
||||||
private static async getDataFromSource(mapping: any) {
|
private static async getDataFromSource(mapping: any) {
|
||||||
try {
|
try {
|
||||||
if (mapping.from_connection_type === 'internal') {
|
if (mapping.from_connection_type === "internal") {
|
||||||
// 내부 DB에서 조회
|
// 내부 DB에서 조회
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await query<any>(
|
||||||
`SELECT * FROM ${mapping.from_table_name}`
|
`SELECT * FROM ${mapping.from_table_name}`,
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
return result as any[];
|
return result;
|
||||||
} else {
|
} else {
|
||||||
// 외부 DB에서 조회 (구현 필요)
|
// 외부 DB에서 조회 (구현 필요)
|
||||||
logger.warn('외부 DB 조회는 아직 구현되지 않았습니다.');
|
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`, error);
|
logger.error(
|
||||||
|
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -478,16 +602,20 @@ export class BatchSchedulerService {
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (mapping.to_connection_type === 'internal') {
|
if (mapping.to_connection_type === "internal") {
|
||||||
// 내부 DB에 삽입
|
// 내부 DB에 삽입
|
||||||
for (const record of data) {
|
for (const record of data) {
|
||||||
try {
|
try {
|
||||||
// 매핑된 컬럼만 추출
|
// 매핑된 컬럼만 추출
|
||||||
const mappedData = this.mapColumns(record, mapping);
|
const mappedData = this.mapColumns(record, mapping);
|
||||||
|
|
||||||
await prisma.$executeRawUnsafe(
|
const columns = Object.keys(mappedData);
|
||||||
`INSERT INTO ${mapping.to_table_name} (${Object.keys(mappedData).join(', ')}) VALUES (${Object.values(mappedData).map(() => '?').join(', ')})`,
|
const values = Object.values(mappedData);
|
||||||
...Object.values(mappedData)
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||||
|
values
|
||||||
);
|
);
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -497,11 +625,14 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 외부 DB에 삽입 (구현 필요)
|
// 외부 DB에 삽입 (구현 필요)
|
||||||
logger.warn('외부 DB 삽입은 아직 구현되지 않았습니다.');
|
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
|
||||||
failedCount = data.length;
|
failedCount = data.length;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`, error);
|
logger.error(
|
||||||
|
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -531,9 +662,9 @@ export class BatchSchedulerService {
|
||||||
}
|
}
|
||||||
this.scheduledTasks.clear();
|
this.scheduledTasks.clear();
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
logger.info('모든 배치 스케줄이 중지되었습니다.');
|
logger.info("모든 배치 스케줄이 중지되었습니다.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('배치 스케줄 중지 실패:', error);
|
logger.error("배치 스케줄 중지 실패:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
||||||
// 수집 관리 서비스
|
// 수집 관리 서비스
|
||||||
// 작성일: 2024-12-23
|
// 작성일: 2024-12-23
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import {
|
import {
|
||||||
DataCollectionConfig,
|
DataCollectionConfig,
|
||||||
CollectionFilter,
|
CollectionFilter,
|
||||||
|
|
@ -9,8 +9,6 @@ import {
|
||||||
CollectionHistory,
|
CollectionHistory,
|
||||||
} from "../types/collectionManagement";
|
} from "../types/collectionManagement";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class CollectionService {
|
export class CollectionService {
|
||||||
/**
|
/**
|
||||||
* 수집 설정 목록 조회
|
* 수집 설정 목록 조회
|
||||||
|
|
@ -18,40 +16,44 @@ export class CollectionService {
|
||||||
static async getCollectionConfigs(
|
static async getCollectionConfigs(
|
||||||
filter: CollectionFilter
|
filter: CollectionFilter
|
||||||
): Promise<DataCollectionConfig[]> {
|
): Promise<DataCollectionConfig[]> {
|
||||||
const whereCondition: any = {
|
const whereConditions: string[] = ["company_code = $1"];
|
||||||
company_code: filter.company_code || "*",
|
const values: any[] = [filter.company_code || "*"];
|
||||||
};
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (filter.config_name) {
|
if (filter.config_name) {
|
||||||
whereCondition.config_name = {
|
whereConditions.push(`config_name ILIKE $${paramIndex++}`);
|
||||||
contains: filter.config_name,
|
values.push(`%${filter.config_name}%`);
|
||||||
mode: "insensitive",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.source_connection_id) {
|
if (filter.source_connection_id) {
|
||||||
whereCondition.source_connection_id = filter.source_connection_id;
|
whereConditions.push(`source_connection_id = $${paramIndex++}`);
|
||||||
|
values.push(filter.source_connection_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.collection_type) {
|
if (filter.collection_type) {
|
||||||
whereCondition.collection_type = filter.collection_type;
|
whereConditions.push(`collection_type = $${paramIndex++}`);
|
||||||
|
values.push(filter.collection_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
whereCondition.is_active = filter.is_active === "Y";
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(filter.is_active === "Y");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
whereCondition.OR = [
|
whereConditions.push(
|
||||||
{ config_name: { contains: filter.search, mode: "insensitive" } },
|
`(config_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
{ description: { contains: filter.search, mode: "insensitive" } },
|
);
|
||||||
];
|
values.push(`%${filter.search}%`);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configs = await prisma.data_collection_configs.findMany({
|
const configs = await query<any>(
|
||||||
where: whereCondition,
|
`SELECT * FROM data_collection_configs
|
||||||
orderBy: { created_date: "desc" },
|
WHERE ${whereConditions.join(" AND ")}
|
||||||
});
|
ORDER BY created_date DESC`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
return configs.map((config: any) => ({
|
return configs.map((config: any) => ({
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -65,9 +67,10 @@ export class CollectionService {
|
||||||
static async getCollectionConfigById(
|
static async getCollectionConfigById(
|
||||||
id: number
|
id: number
|
||||||
): Promise<DataCollectionConfig | null> {
|
): Promise<DataCollectionConfig | null> {
|
||||||
const config = await prisma.data_collection_configs.findUnique({
|
const config = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM data_collection_configs WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
|
|
||||||
|
|
@ -84,15 +87,26 @@ export class CollectionService {
|
||||||
data: DataCollectionConfig
|
data: DataCollectionConfig
|
||||||
): Promise<DataCollectionConfig> {
|
): Promise<DataCollectionConfig> {
|
||||||
const { id, collection_options, ...createData } = data;
|
const { id, collection_options, ...createData } = data;
|
||||||
const config = await prisma.data_collection_configs.create({
|
const config = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO data_collection_configs
|
||||||
...createData,
|
(config_name, company_code, source_connection_id, collection_type,
|
||||||
is_active: data.is_active,
|
collection_options, schedule_cron, is_active, description,
|
||||||
collection_options: collection_options || undefined,
|
created_by, updated_by, created_date, updated_date)
|
||||||
created_date: new Date(),
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
||||||
updated_date: new Date(),
|
RETURNING *`,
|
||||||
},
|
[
|
||||||
});
|
createData.config_name,
|
||||||
|
createData.company_code,
|
||||||
|
createData.source_connection_id,
|
||||||
|
createData.collection_type,
|
||||||
|
collection_options ? JSON.stringify(collection_options) : null,
|
||||||
|
createData.schedule_cron,
|
||||||
|
data.is_active,
|
||||||
|
createData.description,
|
||||||
|
createData.created_by,
|
||||||
|
createData.updated_by,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -107,19 +121,52 @@ export class CollectionService {
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<DataCollectionConfig>
|
data: Partial<DataCollectionConfig>
|
||||||
): Promise<DataCollectionConfig> {
|
): Promise<DataCollectionConfig> {
|
||||||
const updateData: any = {
|
const updateFields: string[] = ["updated_date = NOW()"];
|
||||||
...data,
|
const values: any[] = [];
|
||||||
updated_date: new Date(),
|
let paramIndex = 1;
|
||||||
};
|
|
||||||
|
|
||||||
|
if (data.config_name !== undefined) {
|
||||||
|
updateFields.push(`config_name = $${paramIndex++}`);
|
||||||
|
values.push(data.config_name);
|
||||||
|
}
|
||||||
|
if (data.source_connection_id !== undefined) {
|
||||||
|
updateFields.push(`source_connection_id = $${paramIndex++}`);
|
||||||
|
values.push(data.source_connection_id);
|
||||||
|
}
|
||||||
|
if (data.collection_type !== undefined) {
|
||||||
|
updateFields.push(`collection_type = $${paramIndex++}`);
|
||||||
|
values.push(data.collection_type);
|
||||||
|
}
|
||||||
|
if (data.collection_options !== undefined) {
|
||||||
|
updateFields.push(`collection_options = $${paramIndex++}`);
|
||||||
|
values.push(
|
||||||
|
data.collection_options ? JSON.stringify(data.collection_options) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.schedule_cron !== undefined) {
|
||||||
|
updateFields.push(`schedule_cron = $${paramIndex++}`);
|
||||||
|
values.push(data.schedule_cron);
|
||||||
|
}
|
||||||
if (data.is_active !== undefined) {
|
if (data.is_active !== undefined) {
|
||||||
updateData.is_active = data.is_active;
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(data.is_active);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.updated_by !== undefined) {
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(data.updated_by);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await prisma.data_collection_configs.update({
|
const config = await queryOne<any>(
|
||||||
where: { id },
|
`UPDATE data_collection_configs
|
||||||
data: updateData,
|
SET ${updateFields.join(", ")}
|
||||||
});
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, id]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -131,18 +178,17 @@ export class CollectionService {
|
||||||
* 수집 설정 삭제
|
* 수집 설정 삭제
|
||||||
*/
|
*/
|
||||||
static async deleteCollectionConfig(id: number): Promise<void> {
|
static async deleteCollectionConfig(id: number): Promise<void> {
|
||||||
await prisma.data_collection_configs.delete({
|
await query(`DELETE FROM data_collection_configs WHERE id = $1`, [id]);
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수집 작업 실행
|
* 수집 작업 실행
|
||||||
*/
|
*/
|
||||||
static async executeCollection(configId: number): Promise<CollectionJob> {
|
static async executeCollection(configId: number): Promise<CollectionJob> {
|
||||||
const config = await prisma.data_collection_configs.findUnique({
|
const config = await queryOne<any>(
|
||||||
where: { id: configId },
|
`SELECT * FROM data_collection_configs WHERE id = $1`,
|
||||||
});
|
[configId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new Error("수집 설정을 찾을 수 없습니다.");
|
throw new Error("수집 설정을 찾을 수 없습니다.");
|
||||||
|
|
@ -153,14 +199,13 @@ export class CollectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수집 작업 기록 생성
|
// 수집 작업 기록 생성
|
||||||
const job = await prisma.data_collection_jobs.create({
|
const job = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO data_collection_jobs
|
||||||
config_id: configId,
|
(config_id, job_status, started_at, created_date)
|
||||||
job_status: "running",
|
VALUES ($1, $2, NOW(), NOW())
|
||||||
started_at: new Date(),
|
RETURNING *`,
|
||||||
created_date: new Date(),
|
[configId, "running"]
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// 실제 수집 작업 실행 로직은 여기에 구현
|
// 실제 수집 작업 실행 로직은 여기에 구현
|
||||||
// 현재는 시뮬레이션으로 처리
|
// 현재는 시뮬레이션으로 처리
|
||||||
|
|
@ -171,24 +216,23 @@ export class CollectionService {
|
||||||
|
|
||||||
const recordsCollected = Math.floor(Math.random() * 1000) + 100;
|
const recordsCollected = Math.floor(Math.random() * 1000) + 100;
|
||||||
|
|
||||||
await prisma.data_collection_jobs.update({
|
await query(
|
||||||
where: { id: job.id },
|
`UPDATE data_collection_jobs
|
||||||
data: {
|
SET job_status = $1, completed_at = NOW(), records_processed = $2
|
||||||
job_status: "completed",
|
WHERE id = $3`,
|
||||||
completed_at: new Date(),
|
["completed", recordsCollected, job.id]
|
||||||
records_processed: recordsCollected,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prisma.data_collection_jobs.update({
|
await query(
|
||||||
where: { id: job.id },
|
`UPDATE data_collection_jobs
|
||||||
data: {
|
SET job_status = $1, completed_at = NOW(), error_message = $2
|
||||||
job_status: "failed",
|
WHERE id = $3`,
|
||||||
completed_at: new Date(),
|
[
|
||||||
error_message:
|
"failed",
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
},
|
job.id,
|
||||||
});
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
|
@ -199,24 +243,21 @@ export class CollectionService {
|
||||||
* 수집 작업 목록 조회
|
* 수집 작업 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> {
|
static async getCollectionJobs(configId?: number): Promise<CollectionJob[]> {
|
||||||
const whereCondition: any = {};
|
let sql = `
|
||||||
|
SELECT j.*, c.config_name, c.collection_type
|
||||||
|
FROM data_collection_jobs j
|
||||||
|
LEFT JOIN data_collection_configs c ON j.config_id = c.id
|
||||||
|
`;
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
if (configId) {
|
if (configId) {
|
||||||
whereCondition.config_id = configId;
|
sql += ` WHERE j.config_id = $1`;
|
||||||
|
values.push(configId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobs = await prisma.data_collection_jobs.findMany({
|
sql += ` ORDER BY j.started_at DESC`;
|
||||||
where: whereCondition,
|
|
||||||
orderBy: { started_at: "desc" },
|
const jobs = await query<any>(sql, values);
|
||||||
include: {
|
|
||||||
config: {
|
|
||||||
select: {
|
|
||||||
config_name: true,
|
|
||||||
collection_type: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return jobs as CollectionJob[];
|
return jobs as CollectionJob[];
|
||||||
}
|
}
|
||||||
|
|
@ -227,11 +268,13 @@ export class CollectionService {
|
||||||
static async getCollectionHistory(
|
static async getCollectionHistory(
|
||||||
configId: number
|
configId: number
|
||||||
): Promise<CollectionHistory[]> {
|
): Promise<CollectionHistory[]> {
|
||||||
const history = await prisma.data_collection_jobs.findMany({
|
const history = await query<any>(
|
||||||
where: { config_id: configId },
|
`SELECT * FROM data_collection_jobs
|
||||||
orderBy: { started_at: "desc" },
|
WHERE config_id = $1
|
||||||
take: 50, // 최근 50개 이력
|
ORDER BY started_at DESC
|
||||||
});
|
LIMIT 50`,
|
||||||
|
[configId]
|
||||||
|
);
|
||||||
|
|
||||||
return history.map((item: any) => ({
|
return history.map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import prisma from "../config/database";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export interface CodeCategory {
|
export interface CodeCategory {
|
||||||
|
|
@ -69,30 +68,46 @@ export class CommonCodeService {
|
||||||
try {
|
try {
|
||||||
const { search, isActive, page = 1, size = 20 } = params;
|
const { search, isActive, page = 1, size = 20 } = params;
|
||||||
|
|
||||||
let whereClause: any = {};
|
const whereConditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause.OR = [
|
whereConditions.push(
|
||||||
{ category_name: { contains: search, mode: "insensitive" } },
|
`(category_name ILIKE $${paramIndex} OR category_code ILIKE $${paramIndex})`
|
||||||
{ category_code: { contains: search, mode: "insensitive" } },
|
);
|
||||||
];
|
values.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
whereClause.is_active = isActive ? "Y" : "N";
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(isActive ? "Y" : "N");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
const [categories, total] = await Promise.all([
|
// 카테고리 조회
|
||||||
prisma.code_category.findMany({
|
const categories = await query<CodeCategory>(
|
||||||
where: whereClause,
|
`SELECT * FROM code_category
|
||||||
orderBy: [{ sort_order: "asc" }, { category_code: "asc" }],
|
${whereClause}
|
||||||
skip: offset,
|
ORDER BY sort_order ASC, category_code ASC
|
||||||
take: size,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
}),
|
[...values, size, offset]
|
||||||
prisma.code_category.count({ where: whereClause }),
|
);
|
||||||
]);
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM code_category ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개`
|
`카테고리 조회 완료: ${categories.length}개, 전체: ${total}개`
|
||||||
|
|
@ -115,32 +130,43 @@ export class CommonCodeService {
|
||||||
try {
|
try {
|
||||||
const { search, isActive, page = 1, size = 20 } = params;
|
const { search, isActive, page = 1, size = 20 } = params;
|
||||||
|
|
||||||
let whereClause: any = {
|
const whereConditions: string[] = ["code_category = $1"];
|
||||||
code_category: categoryCode,
|
const values: any[] = [categoryCode];
|
||||||
};
|
let paramIndex = 2;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause.OR = [
|
whereConditions.push(
|
||||||
{ code_name: { contains: search, mode: "insensitive" } },
|
`(code_name ILIKE $${paramIndex} OR code_value ILIKE $${paramIndex})`
|
||||||
{ code_value: { contains: search, mode: "insensitive" } },
|
);
|
||||||
];
|
values.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive !== undefined) {
|
if (isActive !== undefined) {
|
||||||
whereClause.is_active = isActive ? "Y" : "N";
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(isActive ? "Y" : "N");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
|
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
const [codes, total] = await Promise.all([
|
// 코드 조회
|
||||||
prisma.code_info.findMany({
|
const codes = await query<CodeInfo>(
|
||||||
where: whereClause,
|
`SELECT * FROM code_info
|
||||||
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
|
${whereClause}
|
||||||
skip: offset,
|
ORDER BY sort_order ASC, code_value ASC
|
||||||
take: size,
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
}),
|
[...values, size, offset]
|
||||||
prisma.code_info.count({ where: whereClause }),
|
);
|
||||||
]);
|
|
||||||
|
// 전체 개수 조회
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM code_info ${whereClause}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개`
|
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개`
|
||||||
|
|
@ -158,18 +184,22 @@ export class CommonCodeService {
|
||||||
*/
|
*/
|
||||||
async createCategory(data: CreateCategoryData, createdBy: string) {
|
async createCategory(data: CreateCategoryData, createdBy: string) {
|
||||||
try {
|
try {
|
||||||
const category = await prisma.code_category.create({
|
const category = await queryOne<CodeCategory>(
|
||||||
data: {
|
`INSERT INTO code_category
|
||||||
category_code: data.categoryCode,
|
(category_code, category_name, category_name_eng, description, sort_order,
|
||||||
category_name: data.categoryName,
|
is_active, created_by, updated_by, created_date, updated_date)
|
||||||
category_name_eng: data.categoryNameEng,
|
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, NOW(), NOW())
|
||||||
description: data.description,
|
RETURNING *`,
|
||||||
sort_order: data.sortOrder || 0,
|
[
|
||||||
is_active: "Y",
|
data.categoryCode,
|
||||||
created_by: createdBy,
|
data.categoryName,
|
||||||
updated_by: createdBy,
|
data.categoryNameEng || null,
|
||||||
},
|
data.description || null,
|
||||||
});
|
data.sortOrder || 0,
|
||||||
|
createdBy,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`카테고리 생성 완료: ${data.categoryCode}`);
|
logger.info(`카테고리 생성 완료: ${data.categoryCode}`);
|
||||||
return category;
|
return category;
|
||||||
|
|
@ -190,23 +220,49 @@ export class CommonCodeService {
|
||||||
try {
|
try {
|
||||||
// 디버깅: 받은 데이터 로그
|
// 디버깅: 받은 데이터 로그
|
||||||
logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
|
logger.info(`카테고리 수정 데이터:`, { categoryCode, data });
|
||||||
const category = await prisma.code_category.update({
|
|
||||||
where: { category_code: categoryCode },
|
// 동적 UPDATE 쿼리 생성
|
||||||
data: {
|
const updateFields: string[] = [
|
||||||
category_name: data.categoryName,
|
"updated_by = $1",
|
||||||
category_name_eng: data.categoryNameEng,
|
"updated_date = NOW()",
|
||||||
description: data.description,
|
];
|
||||||
sort_order: data.sortOrder,
|
const values: any[] = [updatedBy];
|
||||||
is_active:
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (data.categoryName !== undefined) {
|
||||||
|
updateFields.push(`category_name = $${paramIndex++}`);
|
||||||
|
values.push(data.categoryName);
|
||||||
|
}
|
||||||
|
if (data.categoryNameEng !== undefined) {
|
||||||
|
updateFields.push(`category_name_eng = $${paramIndex++}`);
|
||||||
|
values.push(data.categoryNameEng);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.sortOrder !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||||
|
values.push(data.sortOrder);
|
||||||
|
}
|
||||||
|
if (data.isActive !== undefined) {
|
||||||
|
const activeValue =
|
||||||
typeof data.isActive === "boolean"
|
typeof data.isActive === "boolean"
|
||||||
? data.isActive
|
? data.isActive
|
||||||
? "Y"
|
? "Y"
|
||||||
: "N"
|
: "N"
|
||||||
: data.isActive, // boolean이면 "Y"/"N"으로 변환
|
: data.isActive;
|
||||||
updated_by: updatedBy,
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
updated_date: new Date(),
|
values.push(activeValue);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
const category = await queryOne<CodeCategory>(
|
||||||
|
`UPDATE code_category
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE category_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, categoryCode]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`카테고리 수정 완료: ${categoryCode}`);
|
logger.info(`카테고리 수정 완료: ${categoryCode}`);
|
||||||
return category;
|
return category;
|
||||||
|
|
@ -221,9 +277,9 @@ export class CommonCodeService {
|
||||||
*/
|
*/
|
||||||
async deleteCategory(categoryCode: string) {
|
async deleteCategory(categoryCode: string) {
|
||||||
try {
|
try {
|
||||||
await prisma.code_category.delete({
|
await query(`DELETE FROM code_category WHERE category_code = $1`, [
|
||||||
where: { category_code: categoryCode },
|
categoryCode,
|
||||||
});
|
]);
|
||||||
|
|
||||||
logger.info(`카테고리 삭제 완료: ${categoryCode}`);
|
logger.info(`카테고리 삭제 완료: ${categoryCode}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -241,19 +297,23 @@ export class CommonCodeService {
|
||||||
createdBy: string
|
createdBy: string
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const code = await prisma.code_info.create({
|
const code = await queryOne<CodeInfo>(
|
||||||
data: {
|
`INSERT INTO code_info
|
||||||
code_category: categoryCode,
|
(code_category, code_value, code_name, code_name_eng, description, sort_order,
|
||||||
code_value: data.codeValue,
|
is_active, created_by, updated_by, created_date, updated_date)
|
||||||
code_name: data.codeName,
|
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, NOW(), NOW())
|
||||||
code_name_eng: data.codeNameEng,
|
RETURNING *`,
|
||||||
description: data.description,
|
[
|
||||||
sort_order: data.sortOrder || 0,
|
categoryCode,
|
||||||
is_active: "Y",
|
data.codeValue,
|
||||||
created_by: createdBy,
|
data.codeName,
|
||||||
updated_by: createdBy,
|
data.codeNameEng || null,
|
||||||
},
|
data.description || null,
|
||||||
});
|
data.sortOrder || 0,
|
||||||
|
createdBy,
|
||||||
|
createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`);
|
logger.info(`코드 생성 완료: ${categoryCode}.${data.codeValue}`);
|
||||||
return code;
|
return code;
|
||||||
|
|
@ -278,28 +338,49 @@ export class CommonCodeService {
|
||||||
try {
|
try {
|
||||||
// 디버깅: 받은 데이터 로그
|
// 디버깅: 받은 데이터 로그
|
||||||
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
|
logger.info(`코드 수정 데이터:`, { categoryCode, codeValue, data });
|
||||||
const code = await prisma.code_info.update({
|
|
||||||
where: {
|
// 동적 UPDATE 쿼리 생성
|
||||||
code_category_code_value: {
|
const updateFields: string[] = [
|
||||||
code_category: categoryCode,
|
"updated_by = $1",
|
||||||
code_value: codeValue,
|
"updated_date = NOW()",
|
||||||
},
|
];
|
||||||
},
|
const values: any[] = [updatedBy];
|
||||||
data: {
|
let paramIndex = 2;
|
||||||
code_name: data.codeName,
|
|
||||||
code_name_eng: data.codeNameEng,
|
if (data.codeName !== undefined) {
|
||||||
description: data.description,
|
updateFields.push(`code_name = $${paramIndex++}`);
|
||||||
sort_order: data.sortOrder,
|
values.push(data.codeName);
|
||||||
is_active:
|
}
|
||||||
|
if (data.codeNameEng !== undefined) {
|
||||||
|
updateFields.push(`code_name_eng = $${paramIndex++}`);
|
||||||
|
values.push(data.codeNameEng);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.sortOrder !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||||
|
values.push(data.sortOrder);
|
||||||
|
}
|
||||||
|
if (data.isActive !== undefined) {
|
||||||
|
const activeValue =
|
||||||
typeof data.isActive === "boolean"
|
typeof data.isActive === "boolean"
|
||||||
? data.isActive
|
? data.isActive
|
||||||
? "Y"
|
? "Y"
|
||||||
: "N"
|
: "N"
|
||||||
: data.isActive, // boolean이면 "Y"/"N"으로 변환
|
: data.isActive;
|
||||||
updated_by: updatedBy,
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
updated_date: new Date(),
|
values.push(activeValue);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
const code = await queryOne<CodeInfo>(
|
||||||
|
`UPDATE code_info
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, categoryCode, codeValue]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`);
|
logger.info(`코드 수정 완료: ${categoryCode}.${codeValue}`);
|
||||||
return code;
|
return code;
|
||||||
|
|
@ -314,14 +395,10 @@ export class CommonCodeService {
|
||||||
*/
|
*/
|
||||||
async deleteCode(categoryCode: string, codeValue: string) {
|
async deleteCode(categoryCode: string, codeValue: string) {
|
||||||
try {
|
try {
|
||||||
await prisma.code_info.delete({
|
await query(
|
||||||
where: {
|
`DELETE FROM code_info WHERE code_category = $1 AND code_value = $2`,
|
||||||
code_category_code_value: {
|
[categoryCode, codeValue]
|
||||||
code_category: categoryCode,
|
);
|
||||||
code_value: codeValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`);
|
logger.info(`코드 삭제 완료: ${categoryCode}.${codeValue}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -335,19 +412,18 @@ export class CommonCodeService {
|
||||||
*/
|
*/
|
||||||
async getCodeOptions(categoryCode: string) {
|
async getCodeOptions(categoryCode: string) {
|
||||||
try {
|
try {
|
||||||
const codes = await prisma.code_info.findMany({
|
const codes = await query<{
|
||||||
where: {
|
code_value: string;
|
||||||
code_category: categoryCode,
|
code_name: string;
|
||||||
is_active: "Y",
|
code_name_eng: string | null;
|
||||||
},
|
sort_order: number;
|
||||||
select: {
|
}>(
|
||||||
code_value: true,
|
`SELECT code_value, code_name, code_name_eng, sort_order
|
||||||
code_name: true,
|
FROM code_info
|
||||||
code_name_eng: true,
|
WHERE code_category = $1 AND is_active = 'Y'
|
||||||
sort_order: true,
|
ORDER BY sort_order ASC, code_value ASC`,
|
||||||
},
|
[categoryCode]
|
||||||
orderBy: [{ sort_order: "asc" }, { code_value: "asc" }],
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const options = codes.map((code) => ({
|
const options = codes.map((code) => ({
|
||||||
value: code.code_value,
|
value: code.code_value,
|
||||||
|
|
@ -373,13 +449,14 @@ export class CommonCodeService {
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// 먼저 존재하는 코드들을 확인
|
// 먼저 존재하는 코드들을 확인
|
||||||
const existingCodes = await prisma.code_info.findMany({
|
const codeValues = codes.map((c) => c.codeValue);
|
||||||
where: {
|
const placeholders = codeValues.map((_, i) => `$${i + 2}`).join(", ");
|
||||||
code_category: categoryCode,
|
|
||||||
code_value: { in: codes.map((c) => c.codeValue) },
|
const existingCodes = await query<{ code_value: string }>(
|
||||||
},
|
`SELECT code_value FROM code_info
|
||||||
select: { code_value: true },
|
WHERE code_category = $1 AND code_value IN (${placeholders})`,
|
||||||
});
|
[categoryCode, ...codeValues]
|
||||||
|
);
|
||||||
|
|
||||||
const existingCodeValues = existingCodes.map((c) => c.code_value);
|
const existingCodeValues = existingCodes.map((c) => c.code_value);
|
||||||
const validCodes = codes.filter((c) =>
|
const validCodes = codes.filter((c) =>
|
||||||
|
|
@ -392,23 +469,17 @@ export class CommonCodeService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatePromises = validCodes.map(({ codeValue, sortOrder }) =>
|
// 트랜잭션으로 업데이트
|
||||||
prisma.code_info.update({
|
await transaction(async (client) => {
|
||||||
where: {
|
for (const { codeValue, sortOrder } of validCodes) {
|
||||||
code_category_code_value: {
|
await client.query(
|
||||||
code_category: categoryCode,
|
`UPDATE code_info
|
||||||
code_value: codeValue,
|
SET sort_order = $1, updated_by = $2, updated_date = NOW()
|
||||||
},
|
WHERE code_category = $3 AND code_value = $4`,
|
||||||
},
|
[sortOrder, updatedBy, categoryCode, codeValue]
|
||||||
data: {
|
|
||||||
sort_order: sortOrder,
|
|
||||||
updated_by: updatedBy,
|
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
await Promise.all(updatePromises);
|
});
|
||||||
|
|
||||||
const skippedCodes = codes.filter(
|
const skippedCodes = codes.filter(
|
||||||
(c) => !existingCodeValues.includes(c.codeValue)
|
(c) => !existingCodeValues.includes(c.codeValue)
|
||||||
|
|
@ -460,18 +531,38 @@ export class CommonCodeService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수정 시 자기 자신 제외
|
// SQL 쿼리 생성
|
||||||
if (excludeCategoryCode) {
|
let sql = "";
|
||||||
whereCondition.category_code = {
|
const values: any[] = [];
|
||||||
...whereCondition.category_code,
|
let paramIndex = 1;
|
||||||
not: excludeCategoryCode,
|
|
||||||
};
|
switch (field) {
|
||||||
|
case "categoryCode":
|
||||||
|
sql = `SELECT category_code FROM code_category WHERE category_code = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
|
case "categoryName":
|
||||||
|
sql = `SELECT category_code FROM code_category WHERE category_name = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
|
case "categoryNameEng":
|
||||||
|
sql = `SELECT category_code FROM code_category WHERE category_name_eng = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCategory = await prisma.code_category.findFirst({
|
// 수정 시 자기 자신 제외
|
||||||
where: whereCondition,
|
if (excludeCategoryCode) {
|
||||||
select: { category_code: true },
|
sql += ` AND category_code != $${paramIndex++}`;
|
||||||
});
|
values.push(excludeCategoryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const existingCategory = await queryOne<{ category_code: string }>(
|
||||||
|
sql,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
const isDuplicate = !!existingCategory;
|
const isDuplicate = !!existingCategory;
|
||||||
const fieldNames = {
|
const fieldNames = {
|
||||||
|
|
@ -527,18 +618,36 @@ export class CommonCodeService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수정 시 자기 자신 제외
|
// SQL 쿼리 생성
|
||||||
if (excludeCodeValue) {
|
let sql =
|
||||||
whereCondition.code_value = {
|
"SELECT code_value FROM code_info WHERE code_category = $1 AND ";
|
||||||
...whereCondition.code_value,
|
const values: any[] = [categoryCode];
|
||||||
not: excludeCodeValue,
|
let paramIndex = 2;
|
||||||
};
|
|
||||||
|
switch (field) {
|
||||||
|
case "codeValue":
|
||||||
|
sql += `code_value = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
|
case "codeName":
|
||||||
|
sql += `code_name = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
|
case "codeNameEng":
|
||||||
|
sql += `code_name_eng = $${paramIndex++}`;
|
||||||
|
values.push(trimmedValue);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCode = await prisma.code_info.findFirst({
|
// 수정 시 자기 자신 제외
|
||||||
where: whereCondition,
|
if (excludeCodeValue) {
|
||||||
select: { code_value: true },
|
sql += ` AND code_value != $${paramIndex++}`;
|
||||||
});
|
values.push(excludeCodeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` LIMIT 1`;
|
||||||
|
|
||||||
|
const existingCode = await queryOne<{ code_value: string }>(sql, values);
|
||||||
|
|
||||||
const isDuplicate = !!existingCode;
|
const isDuplicate = !!existingCode;
|
||||||
const fieldNames = {
|
const fieldNames = {
|
||||||
|
|
|
||||||
|
|
@ -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,9 @@ 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(`DELETE FROM component_standards WHERE component_code = $1`, [
|
||||||
where: { component_code },
|
component_code,
|
||||||
});
|
]);
|
||||||
|
|
||||||
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
|
return { message: `컴포넌트가 삭제되었습니다: ${component_code}` };
|
||||||
}
|
}
|
||||||
|
|
@ -195,14 +270,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 +295,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 +335,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 +359,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 +412,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query } from "../database/db";
|
||||||
import {
|
import {
|
||||||
DataMappingConfig,
|
DataMappingConfig,
|
||||||
InboundMapping,
|
InboundMapping,
|
||||||
|
|
@ -11,10 +11,8 @@ import {
|
||||||
} from "../types/dataMappingTypes";
|
} from "../types/dataMappingTypes";
|
||||||
|
|
||||||
export class DataMappingService {
|
export class DataMappingService {
|
||||||
private prisma: PrismaClient;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.prisma = new PrismaClient();
|
// No prisma instance needed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -404,10 +402,10 @@ export class DataMappingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw SQL을 사용한 동적 쿼리
|
// Raw SQL을 사용한 동적 쿼리
|
||||||
const query = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
|
const sql = `SELECT * FROM ${tableName}${mapping.sourceFilter ? ` WHERE ${mapping.sourceFilter}` : ""}`;
|
||||||
console.log(`🔍 [DataMappingService] 쿼리 실행: ${query}`);
|
console.log(`🔍 [DataMappingService] 쿼리 실행: ${sql}`);
|
||||||
|
|
||||||
const result = await this.prisma.$queryRawUnsafe(query);
|
const result = await query<any>(sql, []);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -429,14 +427,14 @@ export class DataMappingService {
|
||||||
const values = Object.values(data);
|
const values = Object.values(data);
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
const query = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
||||||
console.log(`📝 [DataMappingService] INSERT 실행:`, {
|
console.log(`📝 [DataMappingService] INSERT 실행:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
columns,
|
columns,
|
||||||
query,
|
query: sql,
|
||||||
});
|
});
|
||||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
await query(sql, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -460,7 +458,7 @@ export class DataMappingService {
|
||||||
.map((col) => `${col} = EXCLUDED.${col}`)
|
.map((col) => `${col} = EXCLUDED.${col}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const query = `
|
const sql = `
|
||||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||||
VALUES (${placeholders})
|
VALUES (${placeholders})
|
||||||
ON CONFLICT (${keyFields.join(", ")})
|
ON CONFLICT (${keyFields.join(", ")})
|
||||||
|
|
@ -470,9 +468,9 @@ export class DataMappingService {
|
||||||
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
|
console.log(`🔄 [DataMappingService] UPSERT 실행:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyFields,
|
keyFields,
|
||||||
query,
|
query: sql,
|
||||||
});
|
});
|
||||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
await query(sql, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -503,14 +501,14 @@ export class DataMappingService {
|
||||||
...keyFields.map((field) => data[field]),
|
...keyFields.map((field) => data[field]),
|
||||||
];
|
];
|
||||||
|
|
||||||
const query = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
|
const sql = `UPDATE ${tableName} SET ${updateClauses} WHERE ${whereConditions}`;
|
||||||
|
|
||||||
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
|
console.log(`✏️ [DataMappingService] UPDATE 실행:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
keyFields,
|
keyFields,
|
||||||
query,
|
query: sql,
|
||||||
});
|
});
|
||||||
await this.prisma.$executeRawUnsafe(query, ...values);
|
await query(sql, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -570,6 +568,6 @@ export class DataMappingService {
|
||||||
* 리소스 정리
|
* 리소스 정리
|
||||||
*/
|
*/
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
await this.prisma.$disconnect();
|
// No disconnect needed for raw queries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import prisma from "../config/database";
|
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -111,7 +110,7 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 SQL 쿼리 생성
|
// 동적 SQL 쿼리 생성
|
||||||
let query = `SELECT * FROM "${tableName}"`;
|
let sql = `SELECT * FROM "${tableName}"`;
|
||||||
const queryParams: any[] = [];
|
const queryParams: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
|
@ -150,7 +149,7 @@ class DataService {
|
||||||
|
|
||||||
// WHERE 절 추가
|
// WHERE 절 추가
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
query += ` WHERE ${whereConditions.join(" AND ")}`;
|
sql += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ORDER BY 절 추가
|
// ORDER BY 절 추가
|
||||||
|
|
@ -162,7 +161,7 @@ class DataService {
|
||||||
|
|
||||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
|
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
|
||||||
const validDirection = direction === "DESC" ? "DESC" : "ASC";
|
const validDirection = direction === "DESC" ? "DESC" : "ASC";
|
||||||
query += ` ORDER BY "${columnName}" ${validDirection}`;
|
sql += ` ORDER BY "${columnName}" ${validDirection}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 기본 정렬: 최신순 (가능한 컬럼 시도)
|
// 기본 정렬: 최신순 (가능한 컬럼 시도)
|
||||||
|
|
@ -179,23 +178,23 @@ class DataService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableDateColumn) {
|
if (availableDateColumn) {
|
||||||
query += ` ORDER BY "${availableDateColumn}" DESC`;
|
sql += ` ORDER BY "${availableDateColumn}" DESC`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LIMIT과 OFFSET 추가
|
// LIMIT과 OFFSET 추가
|
||||||
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
sql += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
||||||
queryParams.push(limit, offset);
|
queryParams.push(limit, offset);
|
||||||
|
|
||||||
console.log("🔍 실행할 쿼리:", query);
|
console.log("🔍 실행할 쿼리:", sql);
|
||||||
console.log("📊 쿼리 파라미터:", queryParams);
|
console.log("📊 쿼리 파라미터:", queryParams);
|
||||||
|
|
||||||
// 쿼리 실행
|
// 쿼리 실행
|
||||||
const result = await prisma.$queryRawUnsafe(query, ...queryParams);
|
const result = await query<any>(sql, queryParams);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result as any[],
|
data: result,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`데이터 조회 오류 (${tableName}):`, error);
|
console.error(`데이터 조회 오류 (${tableName}):`, error);
|
||||||
|
|
@ -259,18 +258,16 @@ class DataService {
|
||||||
*/
|
*/
|
||||||
private async checkTableExists(tableName: string): Promise<boolean> {
|
private async checkTableExists(tableName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await query<{ exists: boolean }>(
|
||||||
`
|
`SELECT EXISTS (
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
SELECT FROM information_schema.tables
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = $1
|
AND table_name = $1
|
||||||
);
|
)`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (result as any)[0]?.exists || false;
|
return result[0]?.exists || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("테이블 존재 확인 오류:", error);
|
console.error("테이블 존재 확인 오류:", error);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -281,18 +278,16 @@ class DataService {
|
||||||
* 테이블 컬럼 정보 조회 (간단 버전)
|
* 테이블 컬럼 정보 조회 (간단 버전)
|
||||||
*/
|
*/
|
||||||
private async getTableColumnsSimple(tableName: string): Promise<any[]> {
|
private async getTableColumnsSimple(tableName: string): Promise<any[]> {
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await query<any>(
|
||||||
`
|
`SELECT column_name, data_type, is_nullable, column_default
|
||||||
SELECT column_name, data_type, is_nullable, column_default
|
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'
|
||||||
ORDER BY ordinal_position;
|
ORDER BY ordinal_position`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result as any[];
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -304,19 +299,15 @@ class DataService {
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
// column_labels 테이블에서 라벨 조회
|
// column_labels 테이블에서 라벨 조회
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await query<{ label_ko: string }>(
|
||||||
`
|
`SELECT label_ko
|
||||||
SELECT label_ko
|
|
||||||
FROM column_labels
|
FROM column_labels
|
||||||
WHERE table_name = $1 AND column_name = $2
|
WHERE table_name = $1 AND column_name = $2
|
||||||
LIMIT 1;
|
LIMIT 1`,
|
||||||
`,
|
[tableName, columnName]
|
||||||
tableName,
|
|
||||||
columnName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelResult = result as any[];
|
return result[0]?.label_ko || null;
|
||||||
return labelResult[0]?.label_ko || null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
|
// column_labels 테이블이 없거나 오류가 발생하면 null 반환
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// 🔧 Prisma 클라이언트 중복 생성 방지 - 기존 인스턴스 재사용
|
import { query, queryOne } from "../database/db";
|
||||||
import prisma = require("../config/database");
|
|
||||||
|
|
||||||
export interface ControlCondition {
|
export interface ControlCondition {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -82,9 +81,10 @@ export class DataflowControlService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 관계도 정보 조회
|
// 관계도 정보 조회
|
||||||
const diagram = await prisma.dataflow_diagrams.findUnique({
|
const diagram = await queryOne<any>(
|
||||||
where: { diagram_id: diagramId },
|
`SELECT * FROM dataflow_diagrams WHERE diagram_id = $1`,
|
||||||
});
|
[diagramId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!diagram) {
|
if (!diagram) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -527,9 +527,9 @@ export class DataflowControlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 대상 테이블에서 조건에 맞는 데이터 조회
|
// 대상 테이블에서 조건에 맞는 데이터 조회
|
||||||
const queryResult = await prisma.$queryRawUnsafe(
|
const queryResult = await query<Record<string, any>>(
|
||||||
`SELECT ${condition.field} FROM ${tableName} WHERE ${condition.field} = $1 LIMIT 1`,
|
`SELECT ${condition.field} FROM ${tableName} WHERE ${condition.field} = $1 LIMIT 1`,
|
||||||
condition.value
|
[condition.value]
|
||||||
);
|
);
|
||||||
|
|
||||||
dataToCheck =
|
dataToCheck =
|
||||||
|
|
@ -758,14 +758,14 @@ export class DataflowControlService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 동적 테이블 INSERT 실행
|
// 동적 테이블 INSERT 실행
|
||||||
const result = await prisma.$executeRawUnsafe(
|
const placeholders = Object.keys(insertData)
|
||||||
`
|
.map((_, i) => `$${i + 1}`)
|
||||||
INSERT INTO ${targetTable} (${Object.keys(insertData).join(", ")})
|
.join(", ");
|
||||||
VALUES (${Object.keys(insertData)
|
|
||||||
.map(() => "?")
|
const result = await query(
|
||||||
.join(", ")})
|
`INSERT INTO ${targetTable} (${Object.keys(insertData).join(", ")})
|
||||||
`,
|
VALUES (${placeholders})`,
|
||||||
...Object.values(insertData)
|
Object.values(insertData)
|
||||||
);
|
);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
|
|
@ -878,10 +878,7 @@ export class DataflowControlService {
|
||||||
);
|
);
|
||||||
console.log(`📊 쿼리 파라미터:`, allValues);
|
console.log(`📊 쿼리 파라미터:`, allValues);
|
||||||
|
|
||||||
const result = await prisma.$executeRawUnsafe(
|
const result = await query(updateQuery, allValues);
|
||||||
updateQuery,
|
|
||||||
...allValues
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ UPDATE 성공 (${i + 1}/${action.fieldMappings.length}):`,
|
`✅ UPDATE 성공 (${i + 1}/${action.fieldMappings.length}):`,
|
||||||
|
|
@ -937,23 +934,14 @@ export class DataflowControlService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE 액션 실행 - 조건 기반으로만 삭제
|
* DELETE 액션 실행 - 보안상 외부 DB 비활성화
|
||||||
*/
|
*/
|
||||||
private async executeDeleteAction(
|
private async executeDeleteAction(
|
||||||
action: ControlAction,
|
action: ControlAction,
|
||||||
sourceData: Record<string, any>
|
sourceData: Record<string, any>
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
console.log(`🗑️ DELETE 액션 실행 시작:`, {
|
// 보안상 외부 DB에 대한 DELETE 작업은 비활성화
|
||||||
actionName: action.name,
|
throw new Error("보안상 외부 데이터베이스에 대한 DELETE 작업은 허용되지 않습니다. SELECT 쿼리만 사용해주세요.");
|
||||||
conditions: action.conditions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// DELETE는 조건이 필수
|
|
||||||
if (!action.conditions || action.conditions.length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
"DELETE 액션에는 반드시 조건이 필요합니다. 전체 테이블 삭제는 위험합니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
|
@ -967,7 +955,7 @@ export class DataflowControlService {
|
||||||
condition.value !== undefined
|
condition.value !== undefined
|
||||||
) {
|
) {
|
||||||
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
|
// 조건에서 테이블명을 추출 (테이블명.필드명 형식이거나 기본 소스 테이블)
|
||||||
const parts = condition.field.split(".");
|
const parts = condition.field!.split(".");
|
||||||
let tableName: string;
|
let tableName: string;
|
||||||
let fieldName: string;
|
let fieldName: string;
|
||||||
|
|
||||||
|
|
@ -985,7 +973,7 @@ export class DataflowControlService {
|
||||||
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
|
`DELETE 조건에서 테이블을 결정할 수 없습니다. 필드를 "테이블명.필드명" 형식으로 지정하거나 fieldMappings에 targetTable을 설정하세요.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fieldName = condition.field;
|
fieldName = condition.field!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableGroups.has(tableName)) {
|
if (!tableGroups.has(tableName)) {
|
||||||
|
|
@ -1033,10 +1021,7 @@ export class DataflowControlService {
|
||||||
console.log(`🚀 실행할 쿼리:`, deleteQuery);
|
console.log(`🚀 실행할 쿼리:`, deleteQuery);
|
||||||
console.log(`📊 쿼리 파라미터:`, whereValues);
|
console.log(`📊 쿼리 파라미터:`, whereValues);
|
||||||
|
|
||||||
const result = await prisma.$executeRawUnsafe(
|
const result = await query(deleteQuery, whereValues);
|
||||||
deleteQuery,
|
|
||||||
...whereValues
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ DELETE 성공:`, {
|
console.log(`✅ DELETE 성공:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
|
|
@ -1050,14 +1035,14 @@ export class DataflowControlService {
|
||||||
targetTable: tableName,
|
targetTable: tableName,
|
||||||
whereClause,
|
whereClause,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error: unknown) {
|
||||||
console.error(`❌ DELETE 실패:`, {
|
console.error(`❌ DELETE 실패:`, {
|
||||||
table: tableName,
|
table: tableName,
|
||||||
error: error,
|
error: error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userFriendlyMessage =
|
const userFriendlyMessage =
|
||||||
error instanceof Error ? error.message : String(error);
|
error instanceof Error ? (error as Error).message : String(error);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
message: `DELETE 실패: ${tableName}`,
|
message: `DELETE 실패: ${tableName}`,
|
||||||
|
|
@ -1089,18 +1074,15 @@ export class DataflowControlService {
|
||||||
columnName: string
|
columnName: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRawUnsafe<Array<{ exists: boolean }>>(
|
const result = await query<{ exists: boolean }>(
|
||||||
`
|
`SELECT EXISTS (
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'
|
||||||
) as exists
|
) as exists`,
|
||||||
`,
|
[tableName, columnName]
|
||||||
tableName,
|
|
||||||
columnName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return result[0]?.exists || false;
|
return result[0]?.exists || false;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Prisma } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import prisma from "../config/database";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
// 타입 정의
|
// 타입 정의
|
||||||
|
|
@ -43,41 +42,41 @@ export const getDataflowDiagrams = async (
|
||||||
try {
|
try {
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 검색 조건 구성
|
const whereConditions: string[] = [];
|
||||||
const whereClause: {
|
const values: any[] = [];
|
||||||
company_code?: string;
|
let paramIndex = 1;
|
||||||
diagram_name?: {
|
|
||||||
contains: string;
|
|
||||||
mode: "insensitive";
|
|
||||||
};
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
// company_code가 '*'가 아닌 경우에만 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
values.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
whereClause.diagram_name = {
|
whereConditions.push(`diagram_name ILIKE $${paramIndex++}`);
|
||||||
contains: searchTerm,
|
values.push(`%${searchTerm}%`);
|
||||||
mode: "insensitive",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereClause =
|
||||||
|
whereConditions.length > 0
|
||||||
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
// 총 개수 조회
|
// 총 개수 조회
|
||||||
const total = await prisma.dataflow_diagrams.count({
|
const countResult = await queryOne<{ count: string }>(
|
||||||
where: whereClause,
|
`SELECT COUNT(*) as count FROM dataflow_diagrams ${whereClause}`,
|
||||||
});
|
values
|
||||||
|
);
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
// 데이터 조회
|
// 데이터 조회
|
||||||
const diagrams = await prisma.dataflow_diagrams.findMany({
|
const diagrams = await query<any>(
|
||||||
where: whereClause,
|
`SELECT * FROM dataflow_diagrams
|
||||||
orderBy: {
|
${whereClause}
|
||||||
updated_at: "desc",
|
ORDER BY updated_at DESC
|
||||||
},
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
skip: offset,
|
[...values, size, offset]
|
||||||
take: size,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / size);
|
const totalPages = Math.ceil(total / size);
|
||||||
|
|
||||||
|
|
@ -104,21 +103,21 @@ export const getDataflowDiagramById = async (
|
||||||
companyCode: string
|
companyCode: string
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const whereClause: {
|
const whereConditions: string[] = ["diagram_id = $1"];
|
||||||
diagram_id: number;
|
const values: any[] = [diagramId];
|
||||||
company_code?: string;
|
|
||||||
} = {
|
|
||||||
diagram_id: diagramId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
// company_code가 '*'가 아닌 경우에만 필터링
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereConditions.push("company_code = $2");
|
||||||
|
values.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagram = await prisma.dataflow_diagrams.findFirst({
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
where: whereClause,
|
|
||||||
});
|
const diagram = await queryOne<any>(
|
||||||
|
`SELECT * FROM dataflow_diagrams ${whereClause} LIMIT 1`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
return diagram;
|
return diagram;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -134,23 +133,24 @@ export const createDataflowDiagram = async (
|
||||||
data: CreateDataflowDiagramData
|
data: CreateDataflowDiagramData
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const newDiagram = await prisma.dataflow_diagrams.create({
|
const newDiagram = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO dataflow_diagrams
|
||||||
diagram_name: data.diagram_name,
|
(diagram_name, relationships, node_positions, category, control, plan,
|
||||||
relationships: data.relationships as Prisma.InputJsonValue,
|
company_code, created_by, updated_by, created_at, updated_at)
|
||||||
node_positions: data.node_positions as
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||||
| Prisma.InputJsonValue
|
RETURNING *`,
|
||||||
| undefined,
|
[
|
||||||
category: data.category
|
data.diagram_name,
|
||||||
? (data.category as Prisma.InputJsonValue)
|
JSON.stringify(data.relationships),
|
||||||
: undefined,
|
data.node_positions ? JSON.stringify(data.node_positions) : null,
|
||||||
control: data.control as Prisma.InputJsonValue | undefined,
|
data.category ? JSON.stringify(data.category) : null,
|
||||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
data.control ? JSON.stringify(data.control) : null,
|
||||||
company_code: data.company_code,
|
data.plan ? JSON.stringify(data.plan) : null,
|
||||||
created_by: data.created_by,
|
data.company_code,
|
||||||
updated_by: data.updated_by,
|
data.created_by,
|
||||||
},
|
data.updated_by,
|
||||||
});
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return newDiagram;
|
return newDiagram;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -173,21 +173,18 @@ export const updateDataflowDiagram = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
// 먼저 해당 관계도가 존재하는지 확인
|
// 먼저 해당 관계도가 존재하는지 확인
|
||||||
const whereClause: {
|
const whereConditions: string[] = ["diagram_id = $1"];
|
||||||
diagram_id: number;
|
const checkValues: any[] = [diagramId];
|
||||||
company_code?: string;
|
|
||||||
} = {
|
|
||||||
diagram_id: diagramId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereConditions.push("company_code = $2");
|
||||||
|
checkValues.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingDiagram = await prisma.dataflow_diagrams.findFirst({
|
const existingDiagram = await queryOne<any>(
|
||||||
where: whereClause,
|
`SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
|
||||||
});
|
checkValues
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`기존 관계도 조회 결과:`,
|
`기존 관계도 조회 결과:`,
|
||||||
|
|
@ -201,36 +198,45 @@ export const updateDataflowDiagram = async (
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업데이트 실행
|
// 동적 UPDATE 쿼리 생성
|
||||||
const updatedDiagram = await prisma.dataflow_diagrams.update({
|
const updateFields: string[] = ["updated_by = $1", "updated_at = NOW()"];
|
||||||
where: {
|
const values: any[] = [data.updated_by];
|
||||||
diagram_id: diagramId,
|
let paramIndex = 2;
|
||||||
},
|
|
||||||
data: {
|
if (data.diagram_name) {
|
||||||
...(data.diagram_name && { diagram_name: data.diagram_name }),
|
updateFields.push(`diagram_name = $${paramIndex++}`);
|
||||||
...(data.relationships && {
|
values.push(data.diagram_name);
|
||||||
relationships: data.relationships as Prisma.InputJsonValue,
|
}
|
||||||
}),
|
if (data.relationships) {
|
||||||
...(data.node_positions !== undefined && {
|
updateFields.push(`relationships = $${paramIndex++}`);
|
||||||
node_positions: data.node_positions
|
values.push(JSON.stringify(data.relationships));
|
||||||
? (data.node_positions as Prisma.InputJsonValue)
|
}
|
||||||
: Prisma.JsonNull,
|
if (data.node_positions !== undefined) {
|
||||||
}),
|
updateFields.push(`node_positions = $${paramIndex++}`);
|
||||||
...(data.category !== undefined && {
|
values.push(
|
||||||
category: data.category
|
data.node_positions ? JSON.stringify(data.node_positions) : null
|
||||||
? (data.category as Prisma.InputJsonValue)
|
);
|
||||||
: undefined,
|
}
|
||||||
}),
|
if (data.category !== undefined) {
|
||||||
...(data.control !== undefined && {
|
updateFields.push(`category = $${paramIndex++}`);
|
||||||
control: data.control as Prisma.InputJsonValue | undefined,
|
values.push(data.category ? JSON.stringify(data.category) : null);
|
||||||
}),
|
}
|
||||||
...(data.plan !== undefined && {
|
if (data.control !== undefined) {
|
||||||
plan: data.plan as Prisma.InputJsonValue | undefined,
|
updateFields.push(`control = $${paramIndex++}`);
|
||||||
}),
|
values.push(data.control ? JSON.stringify(data.control) : null);
|
||||||
updated_by: data.updated_by,
|
}
|
||||||
updated_at: new Date(),
|
if (data.plan !== undefined) {
|
||||||
},
|
updateFields.push(`plan = $${paramIndex++}`);
|
||||||
});
|
values.push(data.plan ? JSON.stringify(data.plan) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDiagram = await queryOne<any>(
|
||||||
|
`UPDATE dataflow_diagrams
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE diagram_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, diagramId]
|
||||||
|
);
|
||||||
|
|
||||||
return updatedDiagram;
|
return updatedDiagram;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -248,32 +254,27 @@ export const deleteDataflowDiagram = async (
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// 먼저 해당 관계도가 존재하는지 확인
|
// 먼저 해당 관계도가 존재하는지 확인
|
||||||
const whereClause: {
|
const whereConditions: string[] = ["diagram_id = $1"];
|
||||||
diagram_id: number;
|
const values: any[] = [diagramId];
|
||||||
company_code?: string;
|
|
||||||
} = {
|
|
||||||
diagram_id: diagramId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereConditions.push("company_code = $2");
|
||||||
|
values.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingDiagram = await prisma.dataflow_diagrams.findFirst({
|
const existingDiagram = await queryOne<any>(
|
||||||
where: whereClause,
|
`SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
|
||||||
});
|
values
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingDiagram) {
|
if (!existingDiagram) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 삭제 실행
|
// 삭제 실행
|
||||||
await prisma.dataflow_diagrams.delete({
|
await query(`DELETE FROM dataflow_diagrams WHERE diagram_id = $1`, [
|
||||||
where: {
|
diagramId,
|
||||||
diagram_id: diagramId,
|
]);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -293,21 +294,18 @@ export const copyDataflowDiagram = async (
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// 원본 관계도 조회
|
// 원본 관계도 조회
|
||||||
const whereClause: {
|
const whereConditions: string[] = ["diagram_id = $1"];
|
||||||
diagram_id: number;
|
const values: any[] = [diagramId];
|
||||||
company_code?: string;
|
|
||||||
} = {
|
|
||||||
diagram_id: diagramId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereClause.company_code = companyCode;
|
whereConditions.push("company_code = $2");
|
||||||
|
values.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalDiagram = await prisma.dataflow_diagrams.findFirst({
|
const originalDiagram = await queryOne<any>(
|
||||||
where: whereClause,
|
`SELECT * FROM dataflow_diagrams WHERE ${whereConditions.join(" AND ")} LIMIT 1`,
|
||||||
});
|
values
|
||||||
|
);
|
||||||
|
|
||||||
if (!originalDiagram) {
|
if (!originalDiagram) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -325,28 +323,19 @@ export const copyDataflowDiagram = async (
|
||||||
: originalDiagram.diagram_name;
|
: originalDiagram.diagram_name;
|
||||||
|
|
||||||
// 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기
|
// 같은 패턴의 이름들을 찾아서 가장 큰 번호 찾기
|
||||||
const copyWhereClause: {
|
const copyWhereConditions: string[] = ["diagram_name LIKE $1"];
|
||||||
diagram_name: {
|
const copyValues: any[] = [`${baseName}%`];
|
||||||
startsWith: string;
|
|
||||||
};
|
|
||||||
company_code?: string;
|
|
||||||
} = {
|
|
||||||
diagram_name: {
|
|
||||||
startsWith: baseName,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// company_code가 '*'가 아닌 경우에만 필터링
|
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
copyWhereClause.company_code = companyCode;
|
copyWhereConditions.push("company_code = $2");
|
||||||
|
copyValues.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCopies = await prisma.dataflow_diagrams.findMany({
|
const existingCopies = await query<{ diagram_name: string }>(
|
||||||
where: copyWhereClause,
|
`SELECT diagram_name FROM dataflow_diagrams
|
||||||
select: {
|
WHERE ${copyWhereConditions.join(" AND ")}`,
|
||||||
diagram_name: true,
|
copyValues
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
existingCopies.forEach((copy) => {
|
existingCopies.forEach((copy) => {
|
||||||
|
|
@ -363,19 +352,24 @@ export const copyDataflowDiagram = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 관계도 생성
|
// 새로운 관계도 생성
|
||||||
const copiedDiagram = await prisma.dataflow_diagrams.create({
|
const copiedDiagram = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO dataflow_diagrams
|
||||||
diagram_name: copyName,
|
(diagram_name, relationships, node_positions, category, company_code,
|
||||||
relationships: originalDiagram.relationships as Prisma.InputJsonValue,
|
created_by, updated_by, created_at, updated_at)
|
||||||
node_positions: originalDiagram.node_positions
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||||
? (originalDiagram.node_positions as Prisma.InputJsonValue)
|
RETURNING *`,
|
||||||
: Prisma.JsonNull,
|
[
|
||||||
category: originalDiagram.category || undefined,
|
copyName,
|
||||||
company_code: companyCode,
|
JSON.stringify(originalDiagram.relationships),
|
||||||
created_by: userId,
|
originalDiagram.node_positions
|
||||||
updated_by: userId,
|
? JSON.stringify(originalDiagram.node_positions)
|
||||||
},
|
: null,
|
||||||
});
|
originalDiagram.category || null,
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return copiedDiagram;
|
return copiedDiagram;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -390,34 +384,34 @@ export const copyDataflowDiagram = async (
|
||||||
*/
|
*/
|
||||||
export const getAllRelationshipsForButtonControl = async (
|
export const getAllRelationshipsForButtonControl = async (
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<Array<{
|
): Promise<
|
||||||
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
sourceTable: string;
|
sourceTable: string;
|
||||||
targetTable: string;
|
targetTable: string;
|
||||||
category: string;
|
category: string;
|
||||||
}>> => {
|
}>
|
||||||
|
> => {
|
||||||
try {
|
try {
|
||||||
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
|
logger.info(`전체 관계 목록 조회 시작 - companyCode: ${companyCode}`);
|
||||||
|
|
||||||
// dataflow_diagrams 테이블에서 관계도들을 조회
|
// dataflow_diagrams 테이블에서 관계도들을 조회
|
||||||
const diagrams = await prisma.dataflow_diagrams.findMany({
|
const diagrams = await query<{
|
||||||
where: {
|
diagram_id: number;
|
||||||
company_code: companyCode,
|
diagram_name: string;
|
||||||
},
|
relationships: any;
|
||||||
select: {
|
}>(
|
||||||
diagram_id: true,
|
`SELECT diagram_id, diagram_name, relationships
|
||||||
diagram_name: true,
|
FROM dataflow_diagrams
|
||||||
relationships: true,
|
WHERE company_code = $1
|
||||||
},
|
ORDER BY updated_at DESC`,
|
||||||
orderBy: {
|
[companyCode]
|
||||||
updated_at: "desc",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRelationships = diagrams.map((diagram) => {
|
const allRelationships = diagrams.map((diagram) => {
|
||||||
// relationships 구조에서 테이블 정보 추출
|
// relationships 구조에서 테이블 정보 추출
|
||||||
const relationships = diagram.relationships as any || {};
|
const relationships = (diagram.relationships as any) || {};
|
||||||
|
|
||||||
// 테이블 정보 추출
|
// 테이블 정보 추출
|
||||||
let sourceTable = "";
|
let sourceTable = "";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 테이블 관계 생성 데이터 타입
|
// 테이블 관계 생성 데이터 타입
|
||||||
interface CreateTableRelationshipData {
|
interface CreateTableRelationshipData {
|
||||||
diagramId?: number; // 기존 관계도에 추가하는 경우
|
diagramId?: number; // 기존 관계도에 추가하는 경우
|
||||||
|
|
@ -45,33 +43,36 @@ export class DataflowService {
|
||||||
if (!diagramId) {
|
if (!diagramId) {
|
||||||
// 새로운 관계도인 경우, 새로운 diagram_id 생성
|
// 새로운 관계도인 경우, 새로운 diagram_id 생성
|
||||||
// 현재 최대 diagram_id + 1
|
// 현재 최대 diagram_id + 1
|
||||||
const maxDiagramId = await prisma.table_relationships.findFirst({
|
const maxDiagramId = await queryOne<{ diagram_id: number }>(
|
||||||
where: {
|
`SELECT diagram_id FROM table_relationships
|
||||||
company_code: data.companyCode,
|
WHERE company_code = $1
|
||||||
},
|
ORDER BY diagram_id DESC
|
||||||
orderBy: {
|
LIMIT 1`,
|
||||||
diagram_id: "desc",
|
[data.companyCode]
|
||||||
},
|
);
|
||||||
select: {
|
|
||||||
diagram_id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
diagramId = (maxDiagramId?.diagram_id || 0) + 1;
|
diagramId = (maxDiagramId?.diagram_id || 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 관계 확인 (같은 diagram_id 내에서)
|
// 중복 관계 확인 (같은 diagram_id 내에서)
|
||||||
const existingRelationship = await prisma.table_relationships.findFirst({
|
const existingRelationship = await queryOne(
|
||||||
where: {
|
`SELECT * FROM table_relationships
|
||||||
diagram_id: diagramId,
|
WHERE diagram_id = $1
|
||||||
from_table_name: data.fromTableName,
|
AND from_table_name = $2
|
||||||
from_column_name: data.fromColumnName,
|
AND from_column_name = $3
|
||||||
to_table_name: data.toTableName,
|
AND to_table_name = $4
|
||||||
to_column_name: data.toColumnName,
|
AND to_column_name = $5
|
||||||
company_code: data.companyCode,
|
AND company_code = $6
|
||||||
is_active: "Y",
|
AND is_active = 'Y'`,
|
||||||
},
|
[
|
||||||
});
|
diagramId,
|
||||||
|
data.fromTableName,
|
||||||
|
data.fromColumnName,
|
||||||
|
data.toTableName,
|
||||||
|
data.toColumnName,
|
||||||
|
data.companyCode,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (existingRelationship) {
|
if (existingRelationship) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -80,22 +81,28 @@ export class DataflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새 관계 생성
|
// 새 관계 생성
|
||||||
const relationship = await prisma.table_relationships.create({
|
const relationship = await queryOne(
|
||||||
data: {
|
`INSERT INTO table_relationships (
|
||||||
diagram_id: diagramId,
|
diagram_id, relationship_name, from_table_name, from_column_name,
|
||||||
relationship_name: data.relationshipName,
|
to_table_name, to_column_name, relationship_type, connection_type,
|
||||||
from_table_name: data.fromTableName,
|
company_code, settings, created_by, updated_by, created_at, updated_at
|
||||||
from_column_name: data.fromColumnName,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), now())
|
||||||
to_table_name: data.toTableName,
|
RETURNING *`,
|
||||||
to_column_name: data.toColumnName,
|
[
|
||||||
relationship_type: data.relationshipType,
|
diagramId,
|
||||||
connection_type: data.connectionType,
|
data.relationshipName,
|
||||||
company_code: data.companyCode,
|
data.fromTableName,
|
||||||
settings: data.settings,
|
data.fromColumnName,
|
||||||
created_by: data.createdBy,
|
data.toTableName,
|
||||||
updated_by: data.createdBy,
|
data.toColumnName,
|
||||||
},
|
data.relationshipType,
|
||||||
});
|
data.connectionType,
|
||||||
|
data.companyCode,
|
||||||
|
JSON.stringify(data.settings),
|
||||||
|
data.createdBy,
|
||||||
|
data.createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블 관계 생성 완료 - ID: ${relationship.relationship_id}, Diagram ID: ${relationship.diagram_id}`
|
`DataflowService: 테이블 관계 생성 완료 - ID: ${relationship.relationship_id}, Diagram ID: ${relationship.diagram_id}`
|
||||||
|
|
@ -117,20 +124,16 @@ export class DataflowService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 관리자는 모든 회사의 관계를 볼 수 있음
|
// 관리자는 모든 회사의 관계를 볼 수 있음
|
||||||
const whereCondition: any = {
|
let queryText = `SELECT * FROM table_relationships WHERE is_active = 'Y'`;
|
||||||
is_active: "Y",
|
const params: any[] = [];
|
||||||
};
|
|
||||||
|
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $1`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
queryText += ` ORDER BY created_date DESC`;
|
||||||
where: whereCondition,
|
const relationships = await query(queryText, params);
|
||||||
orderBy: {
|
|
||||||
created_date: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블 관계 목록 조회 완료 - ${relationships.length}개`
|
`DataflowService: 테이블 관계 목록 조회 완료 - ${relationships.length}개`
|
||||||
|
|
@ -151,19 +154,16 @@ export class DataflowService {
|
||||||
`DataflowService: 테이블 관계 조회 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}`
|
`DataflowService: 테이블 관계 조회 시작 - ID: ${relationshipId}, 회사코드: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `SELECT * FROM table_relationships WHERE relationship_id = $1 AND is_active = 'Y'`;
|
||||||
relationship_id: relationshipId,
|
const params: any[] = [relationshipId];
|
||||||
is_active: "Y",
|
|
||||||
};
|
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationship = await prisma.table_relationships.findFirst({
|
const relationship = await queryOne(queryText, params);
|
||||||
where: whereCondition,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relationship) {
|
if (relationship) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -206,15 +206,55 @@ export class DataflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 관계 수정
|
// 관계 수정
|
||||||
const relationship = await prisma.table_relationships.update({
|
const updates: string[] = [];
|
||||||
where: {
|
const params: any[] = [];
|
||||||
relationship_id: relationshipId,
|
let paramIndex = 1;
|
||||||
},
|
|
||||||
data: {
|
if (updateData.relationshipName !== undefined) {
|
||||||
...updateData,
|
updates.push(`relationship_name = $${paramIndex++}`);
|
||||||
updated_date: new Date(),
|
params.push(updateData.relationshipName);
|
||||||
},
|
}
|
||||||
});
|
if (updateData.fromTableName !== undefined) {
|
||||||
|
updates.push(`from_table_name = $${paramIndex++}`);
|
||||||
|
params.push(updateData.fromTableName);
|
||||||
|
}
|
||||||
|
if (updateData.fromColumnName !== undefined) {
|
||||||
|
updates.push(`from_column_name = $${paramIndex++}`);
|
||||||
|
params.push(updateData.fromColumnName);
|
||||||
|
}
|
||||||
|
if (updateData.toTableName !== undefined) {
|
||||||
|
updates.push(`to_table_name = $${paramIndex++}`);
|
||||||
|
params.push(updateData.toTableName);
|
||||||
|
}
|
||||||
|
if (updateData.toColumnName !== undefined) {
|
||||||
|
updates.push(`to_column_name = $${paramIndex++}`);
|
||||||
|
params.push(updateData.toColumnName);
|
||||||
|
}
|
||||||
|
if (updateData.relationshipType !== undefined) {
|
||||||
|
updates.push(`relationship_type = $${paramIndex++}`);
|
||||||
|
params.push(updateData.relationshipType);
|
||||||
|
}
|
||||||
|
if (updateData.connectionType !== undefined) {
|
||||||
|
updates.push(`connection_type = $${paramIndex++}`);
|
||||||
|
params.push(updateData.connectionType);
|
||||||
|
}
|
||||||
|
if (updateData.settings !== undefined) {
|
||||||
|
updates.push(`settings = $${paramIndex++}`);
|
||||||
|
params.push(JSON.stringify(updateData.settings));
|
||||||
|
}
|
||||||
|
updates.push(`updated_by = $${paramIndex++}`);
|
||||||
|
params.push(updateData.updatedBy);
|
||||||
|
updates.push(`updated_date = now()`);
|
||||||
|
|
||||||
|
params.push(relationshipId);
|
||||||
|
|
||||||
|
const relationship = await queryOne(
|
||||||
|
`UPDATE table_relationships
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE relationship_id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블 관계 수정 완료 - ID: ${relationshipId}`
|
`DataflowService: 테이블 관계 수정 완료 - ID: ${relationshipId}`
|
||||||
|
|
@ -245,15 +285,12 @@ export class DataflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소프트 삭제 (is_active = 'N')
|
// 소프트 삭제 (is_active = 'N')
|
||||||
await prisma.table_relationships.update({
|
await query(
|
||||||
where: {
|
`UPDATE table_relationships
|
||||||
relationship_id: relationshipId,
|
SET is_active = 'N', updated_date = now()
|
||||||
},
|
WHERE relationship_id = $1`,
|
||||||
data: {
|
[relationshipId]
|
||||||
is_active: "N",
|
);
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블 관계 삭제 완료 - ID: ${relationshipId}`
|
`DataflowService: 테이블 관계 삭제 완료 - ID: ${relationshipId}`
|
||||||
|
|
@ -274,22 +311,21 @@ export class DataflowService {
|
||||||
`DataflowService: 테이블별 관계 조회 시작 - 테이블: ${tableName}, 회사코드: ${companyCode}`
|
`DataflowService: 테이블별 관계 조회 시작 - 테이블: ${tableName}, 회사코드: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
OR: [{ from_table_name: tableName }, { to_table_name: tableName }],
|
SELECT * FROM table_relationships
|
||||||
is_active: "Y",
|
WHERE (from_table_name = $1 OR to_table_name = $1)
|
||||||
};
|
AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const params: any[] = [tableName];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
queryText += ` ORDER BY created_date DESC`;
|
||||||
where: whereCondition,
|
const relationships = await query(queryText, params);
|
||||||
orderBy: {
|
|
||||||
created_date: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블별 관계 조회 완료 - ${relationships.length}개`
|
`DataflowService: 테이블별 관계 조회 완료 - ${relationships.length}개`
|
||||||
|
|
@ -313,22 +349,20 @@ export class DataflowService {
|
||||||
`DataflowService: 연결타입별 관계 조회 시작 - 타입: ${connectionType}, 회사코드: ${companyCode}`
|
`DataflowService: 연결타입별 관계 조회 시작 - 타입: ${connectionType}, 회사코드: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
connection_type: connectionType,
|
SELECT * FROM table_relationships
|
||||||
is_active: "Y",
|
WHERE connection_type = $1 AND is_active = 'Y'
|
||||||
};
|
`;
|
||||||
|
const params: any[] = [connectionType];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
queryText += ` ORDER BY created_date DESC`;
|
||||||
where: whereCondition,
|
const relationships = await query(queryText, params);
|
||||||
orderBy: {
|
|
||||||
created_date: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 연결타입별 관계 조회 완료 - ${relationships.length}개`
|
`DataflowService: 연결타입별 관계 조회 완료 - ${relationships.length}개`
|
||||||
|
|
@ -349,47 +383,53 @@ export class DataflowService {
|
||||||
`DataflowService: 관계 통계 조회 시작 - 회사코드: ${companyCode}`
|
`DataflowService: 관계 통계 조회 시작 - 회사코드: ${companyCode}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let whereClause = `WHERE is_active = 'Y'`;
|
||||||
is_active: "Y",
|
const params: any[] = [];
|
||||||
};
|
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
whereClause += ` AND company_code = $1`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전체 관계 수
|
// 전체 관계 수
|
||||||
const totalCount = await prisma.table_relationships.count({
|
const totalCountResult = await queryOne<{ count: string }>(
|
||||||
where: whereCondition,
|
`SELECT COUNT(*) as count FROM table_relationships ${whereClause}`,
|
||||||
});
|
params
|
||||||
|
);
|
||||||
|
const totalCount = parseInt(totalCountResult?.count || "0", 10);
|
||||||
|
|
||||||
// 관계 타입별 통계
|
// 관계 타입별 통계
|
||||||
const relationshipTypeStats = await prisma.table_relationships.groupBy({
|
const relationshipTypeStats = await query<{
|
||||||
by: ["relationship_type"],
|
relationship_type: string;
|
||||||
where: whereCondition,
|
count: string;
|
||||||
_count: {
|
}>(
|
||||||
relationship_id: true,
|
`SELECT relationship_type, COUNT(*) as count
|
||||||
},
|
FROM table_relationships ${whereClause}
|
||||||
});
|
GROUP BY relationship_type`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
// 연결 타입별 통계
|
// 연결 타입별 통계
|
||||||
const connectionTypeStats = await prisma.table_relationships.groupBy({
|
const connectionTypeStats = await query<{
|
||||||
by: ["connection_type"],
|
connection_type: string;
|
||||||
where: whereCondition,
|
count: string;
|
||||||
_count: {
|
}>(
|
||||||
relationship_id: true,
|
`SELECT connection_type, COUNT(*) as count
|
||||||
},
|
FROM table_relationships ${whereClause}
|
||||||
});
|
GROUP BY connection_type`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalCount,
|
totalCount,
|
||||||
relationshipTypeStats: relationshipTypeStats.map((stat) => ({
|
relationshipTypeStats: relationshipTypeStats.map((stat) => ({
|
||||||
type: stat.relationship_type,
|
type: stat.relationship_type,
|
||||||
count: stat._count.relationship_id,
|
count: parseInt(stat.count, 10),
|
||||||
})),
|
})),
|
||||||
connectionTypeStats: connectionTypeStats.map((stat) => ({
|
connectionTypeStats: connectionTypeStats.map((stat) => ({
|
||||||
type: stat.connection_type,
|
type: stat.connection_type,
|
||||||
count: stat._count.relationship_id,
|
count: parseInt(stat.count, 10),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -422,19 +462,25 @@ export class DataflowService {
|
||||||
`DataflowService: 데이터 연결 생성 시작 - 관계ID: ${linkData.relationshipId}`
|
`DataflowService: 데이터 연결 생성 시작 - 관계ID: ${linkData.relationshipId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const bridge = await prisma.data_relationship_bridge.create({
|
const bridge = await queryOne(
|
||||||
data: {
|
`INSERT INTO data_relationship_bridge (
|
||||||
relationship_id: linkData.relationshipId,
|
relationship_id, from_table_name, from_column_name, to_table_name,
|
||||||
from_table_name: linkData.fromTableName,
|
to_column_name, connection_type, company_code, bridge_data,
|
||||||
from_column_name: linkData.fromColumnName,
|
created_by, created_at
|
||||||
to_table_name: linkData.toTableName,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
|
||||||
to_column_name: linkData.toColumnName,
|
RETURNING *`,
|
||||||
connection_type: linkData.connectionType,
|
[
|
||||||
company_code: linkData.companyCode,
|
linkData.relationshipId,
|
||||||
bridge_data: linkData.bridgeData || {},
|
linkData.fromTableName,
|
||||||
created_by: linkData.createdBy,
|
linkData.fromColumnName,
|
||||||
},
|
linkData.toTableName,
|
||||||
});
|
linkData.toColumnName,
|
||||||
|
linkData.connectionType,
|
||||||
|
linkData.companyCode,
|
||||||
|
JSON.stringify(linkData.bridgeData || {}),
|
||||||
|
linkData.createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 데이터 연결 생성 완료 - Bridge ID: ${bridge.bridge_id}`
|
`DataflowService: 데이터 연결 생성 완료 - Bridge ID: ${bridge.bridge_id}`
|
||||||
|
|
@ -458,21 +504,20 @@ export class DataflowService {
|
||||||
`DataflowService: 관계별 연결 데이터 조회 시작 - 관계ID: ${relationshipId}`
|
`DataflowService: 관계별 연결 데이터 조회 시작 - 관계ID: ${relationshipId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
relationship_id: relationshipId,
|
SELECT * FROM data_relationship_bridge
|
||||||
is_active: "Y",
|
WHERE relationship_id = $1 AND is_active = 'Y'
|
||||||
};
|
`;
|
||||||
|
const params: any[] = [relationshipId];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedData = await prisma.data_relationship_bridge.findMany({
|
queryText += ` ORDER BY created_at DESC`;
|
||||||
where: whereCondition,
|
const linkedData = await query(queryText, params);
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
// include 제거 - relationship 관계가 스키마에 정의되지 않음
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 관계별 연결 데이터 조회 완료 - ${linkedData.length}건`
|
`DataflowService: 관계별 연결 데이터 조회 완료 - ${linkedData.length}건`
|
||||||
|
|
@ -497,23 +542,22 @@ export class DataflowService {
|
||||||
`DataflowService: 테이블별 연결 데이터 조회 시작 - 테이블: ${tableName}`
|
`DataflowService: 테이블별 연결 데이터 조회 시작 - 테이블: ${tableName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
OR: [{ from_table_name: tableName }, { to_table_name: tableName }],
|
SELECT * FROM data_relationship_bridge
|
||||||
is_active: "Y",
|
WHERE (from_table_name = $1 OR to_table_name = $1) AND is_active = 'Y'
|
||||||
};
|
`;
|
||||||
|
const params: any[] = [tableName];
|
||||||
|
|
||||||
// keyValue 파라미터는 더 이상 사용하지 않음 (key_value 필드 제거됨)
|
// keyValue 파라미터는 더 이상 사용하지 않음 (key_value 필드 제거됨)
|
||||||
|
|
||||||
// 회사코드 필터링
|
// 회사코드 필터링
|
||||||
if (companyCode && companyCode !== "*") {
|
if (companyCode && companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $2`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const linkedData = await prisma.data_relationship_bridge.findMany({
|
queryText += ` ORDER BY created_at DESC`;
|
||||||
where: whereCondition,
|
const linkedData = await query(queryText, params);
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
// include 제거 - relationship 관계가 스키마에 정의되지 않음
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 테이블별 연결 데이터 조회 완료 - ${linkedData.length}건`
|
`DataflowService: 테이블별 연결 데이터 조회 완료 - ${linkedData.length}건`
|
||||||
|
|
@ -541,23 +585,25 @@ export class DataflowService {
|
||||||
`DataflowService: 데이터 연결 수정 시작 - Bridge ID: ${bridgeId}`
|
`DataflowService: 데이터 연결 수정 시작 - Bridge ID: ${bridgeId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
bridge_id: bridgeId,
|
UPDATE data_relationship_bridge
|
||||||
is_active: "Y",
|
SET bridge_data = $1, updated_by = $2, updated_at = now()
|
||||||
};
|
WHERE bridge_id = $3 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const params: any[] = [
|
||||||
|
JSON.stringify(updateData.bridgeData),
|
||||||
|
updateData.updatedBy,
|
||||||
|
bridgeId,
|
||||||
|
];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $4`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedBridge = await prisma.data_relationship_bridge.update({
|
queryText += ` RETURNING *`;
|
||||||
where: whereCondition,
|
const updatedBridge = await queryOne(queryText, params);
|
||||||
data: {
|
|
||||||
...updateData,
|
|
||||||
updated_at: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 데이터 연결 수정 완료 - Bridge ID: ${bridgeId}`
|
`DataflowService: 데이터 연결 수정 완료 - Bridge ID: ${bridgeId}`
|
||||||
|
|
@ -582,24 +628,20 @@ export class DataflowService {
|
||||||
`DataflowService: 데이터 연결 삭제 시작 - Bridge ID: ${bridgeId}`
|
`DataflowService: 데이터 연결 삭제 시작 - Bridge ID: ${bridgeId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
bridge_id: bridgeId,
|
UPDATE data_relationship_bridge
|
||||||
is_active: "Y",
|
SET is_active = 'N', updated_at = now(), updated_by = $1
|
||||||
};
|
WHERE bridge_id = $2 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const params: any[] = [deletedBy, bridgeId];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $3`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.data_relationship_bridge.update({
|
await query(queryText, params);
|
||||||
where: whereCondition,
|
|
||||||
data: {
|
|
||||||
is_active: "N",
|
|
||||||
updated_at: new Date(),
|
|
||||||
updated_by: deletedBy,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 데이터 연결 삭제 완료 - Bridge ID: ${bridgeId}`
|
`DataflowService: 데이터 연결 삭제 완료 - Bridge ID: ${bridgeId}`
|
||||||
|
|
@ -624,29 +666,25 @@ export class DataflowService {
|
||||||
`DataflowService: 관계별 모든 데이터 연결 삭제 시작 - 관계ID: ${relationshipId}`
|
`DataflowService: 관계별 모든 데이터 연결 삭제 시작 - 관계ID: ${relationshipId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const whereCondition: any = {
|
let queryText = `
|
||||||
relationship_id: relationshipId,
|
UPDATE data_relationship_bridge
|
||||||
is_active: "Y",
|
SET is_active = 'N', updated_at = now(), updated_by = $1
|
||||||
};
|
WHERE relationship_id = $2 AND is_active = 'Y'
|
||||||
|
`;
|
||||||
|
const params: any[] = [deletedBy, relationshipId];
|
||||||
|
|
||||||
// 관리자가 아닌 경우 회사코드 제한
|
// 관리자가 아닌 경우 회사코드 제한
|
||||||
if (companyCode !== "*") {
|
if (companyCode !== "*") {
|
||||||
whereCondition.company_code = companyCode;
|
queryText += ` AND company_code = $3`;
|
||||||
|
params.push(companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.data_relationship_bridge.updateMany({
|
const result = await query(queryText, params);
|
||||||
where: whereCondition,
|
|
||||||
data: {
|
|
||||||
is_active: "N",
|
|
||||||
updated_at: new Date(),
|
|
||||||
updated_by: deletedBy,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 관계별 모든 데이터 연결 삭제 완료 - ${result.count}건`
|
`DataflowService: 관계별 모든 데이터 연결 삭제 완료 - ${result.length}건`
|
||||||
);
|
);
|
||||||
return result.count;
|
return result.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("DataflowService: 관계별 모든 데이터 연결 삭제 실패", error);
|
logger.error("DataflowService: 관계별 모든 데이터 연결 삭제 실패", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -670,47 +708,51 @@ export class DataflowService {
|
||||||
logger.info(`DataflowService: 테이블 데이터 조회 시작 - ${tableName}`);
|
logger.info(`DataflowService: 테이블 데이터 조회 시작 - ${tableName}`);
|
||||||
|
|
||||||
// 테이블 존재 여부 확인 (정보 스키마 사용)
|
// 테이블 존재 여부 확인 (정보 스키마 사용)
|
||||||
const tableExists = await prisma.$queryRaw`
|
const tableExists = await query(
|
||||||
SELECT table_name
|
`SELECT table_name
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
WHERE table_name = ${tableName.toLowerCase()}
|
WHERE table_name = $1 AND table_schema = 'public'`,
|
||||||
AND table_schema = 'public'
|
[tableName.toLowerCase()]
|
||||||
`;
|
);
|
||||||
|
|
||||||
if (
|
if (!tableExists || tableExists.length === 0) {
|
||||||
!tableExists ||
|
|
||||||
(Array.isArray(tableExists) && tableExists.length === 0)
|
|
||||||
) {
|
|
||||||
throw new Error(`테이블 '${tableName}'이 존재하지 않습니다.`);
|
throw new Error(`테이블 '${tableName}'이 존재하지 않습니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전체 데이터 개수 조회
|
// 전체 데이터 개수 조회 및 데이터 조회
|
||||||
let totalCountQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
|
let totalCountQuery = `SELECT COUNT(*) as total FROM "${tableName}"`;
|
||||||
let dataQuery = `SELECT * FROM "${tableName}"`;
|
let dataQuery = `SELECT * FROM "${tableName}"`;
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
|
||||||
// 검색 조건 추가
|
// 검색 조건 추가 (SQL Injection 방지를 위해 파라미터 바인딩 사용)
|
||||||
if (search && searchColumn) {
|
if (search && searchColumn) {
|
||||||
const whereCondition = `WHERE "${searchColumn}" ILIKE '%${search}%'`;
|
const paramIndex = queryParams.length + 1;
|
||||||
|
const whereCondition = `WHERE "${searchColumn}" ILIKE $${paramIndex}`;
|
||||||
totalCountQuery += ` ${whereCondition}`;
|
totalCountQuery += ` ${whereCondition}`;
|
||||||
dataQuery += ` ${whereCondition}`;
|
dataQuery += ` ${whereCondition}`;
|
||||||
|
queryParams.push(`%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이징 처리
|
// 페이징 처리
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
dataQuery += ` ORDER BY 1 LIMIT ${limit} OFFSET ${offset}`;
|
const limitIndex = queryParams.length + 1;
|
||||||
|
const offsetIndex = queryParams.length + 2;
|
||||||
|
dataQuery += ` ORDER BY 1 LIMIT $${limitIndex} OFFSET $${offsetIndex}`;
|
||||||
|
|
||||||
|
const dataQueryParams = [...queryParams, limit, offset];
|
||||||
|
|
||||||
// 실제 쿼리 실행
|
// 실제 쿼리 실행
|
||||||
const [totalResult, dataResult] = await Promise.all([
|
const [totalResult, dataResult] = await Promise.all([
|
||||||
prisma.$queryRawUnsafe(totalCountQuery),
|
query(totalCountQuery, queryParams.length > 0 ? queryParams : []),
|
||||||
prisma.$queryRawUnsafe(dataQuery),
|
query(dataQuery, dataQueryParams),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const total =
|
const total =
|
||||||
Array.isArray(totalResult) && totalResult.length > 0
|
totalResult && totalResult.length > 0
|
||||||
? Number((totalResult[0] as any).total)
|
? Number((totalResult[0] as any).total)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const data = Array.isArray(dataResult) ? dataResult : [];
|
const data = dataResult || [];
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
data,
|
data,
|
||||||
|
|
@ -752,52 +794,43 @@ export class DataflowService {
|
||||||
`DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}`
|
`DataflowService: 관계도 목록 조회 시작 - ${companyCode}, page: ${page}, size: ${size}, search: ${searchTerm}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// diagram_id별로 그룹화하여 조회
|
// WHERE 조건 구성
|
||||||
const whereCondition = {
|
const params: any[] = [companyCode];
|
||||||
company_code: companyCode,
|
let whereClause = `WHERE company_code = $1 AND is_active = 'Y'`;
|
||||||
is_active: "Y",
|
|
||||||
...(searchTerm && {
|
if (searchTerm) {
|
||||||
OR: [
|
whereClause += ` AND (
|
||||||
{
|
relationship_name ILIKE $2 OR
|
||||||
relationship_name: {
|
from_table_name ILIKE $2 OR
|
||||||
contains: searchTerm,
|
to_table_name ILIKE $2
|
||||||
mode: "insensitive" as any,
|
)`;
|
||||||
},
|
params.push(`%${searchTerm}%`);
|
||||||
},
|
}
|
||||||
{
|
|
||||||
from_table_name: {
|
|
||||||
contains: searchTerm,
|
|
||||||
mode: "insensitive" as any,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to_table_name: {
|
|
||||||
contains: searchTerm,
|
|
||||||
mode: "insensitive" as any,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// diagram_id별로 그룹화된 데이터 조회
|
// diagram_id별로 그룹화된 데이터 조회
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
const relationships = await query<{
|
||||||
where: whereCondition,
|
relationship_id: number;
|
||||||
select: {
|
diagram_id: number;
|
||||||
relationship_id: true,
|
relationship_name: string;
|
||||||
diagram_id: true,
|
from_table_name: string;
|
||||||
relationship_name: true,
|
to_table_name: string;
|
||||||
from_table_name: true,
|
connection_type: string;
|
||||||
to_table_name: true,
|
relationship_type: string;
|
||||||
connection_type: true,
|
created_date: Date;
|
||||||
relationship_type: true,
|
created_by: string;
|
||||||
created_date: true,
|
updated_date: Date;
|
||||||
created_by: true,
|
updated_by: string;
|
||||||
updated_date: true,
|
}>(
|
||||||
updated_by: true,
|
`SELECT
|
||||||
},
|
relationship_id, diagram_id, relationship_name,
|
||||||
orderBy: [{ diagram_id: "asc" }, { created_date: "desc" }],
|
from_table_name, to_table_name, connection_type,
|
||||||
});
|
relationship_type, created_date, created_by,
|
||||||
|
updated_date, updated_by
|
||||||
|
FROM table_relationships
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY diagram_id ASC, created_date DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
// diagram_id별로 그룹화
|
// diagram_id별로 그룹화
|
||||||
const diagramMap = new Map<number, any>();
|
const diagramMap = new Map<number, any>();
|
||||||
|
|
@ -880,16 +913,14 @@ export class DataflowService {
|
||||||
`DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}`
|
`DataflowService: 관계도 관계 조회 시작 - ${companyCode}, diagram: ${diagramName}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
const relationships = await query(
|
||||||
where: {
|
`SELECT * FROM table_relationships
|
||||||
company_code: companyCode,
|
WHERE company_code = $1
|
||||||
relationship_name: diagramName,
|
AND relationship_name = $2
|
||||||
is_active: "Y",
|
AND is_active = 'Y'
|
||||||
},
|
ORDER BY created_date ASC`,
|
||||||
orderBy: {
|
[companyCode, diagramName]
|
||||||
created_date: "asc",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계`
|
`DataflowService: 관계도 관계 조회 완료 - ${diagramName}, ${relationships.length}개 관계`
|
||||||
|
|
@ -916,13 +947,27 @@ export class DataflowService {
|
||||||
logger.info(`DataflowService: 관계도 복사 시작 - ${originalDiagramName}`);
|
logger.info(`DataflowService: 관계도 복사 시작 - ${originalDiagramName}`);
|
||||||
|
|
||||||
// 원본 관계도의 모든 관계 조회
|
// 원본 관계도의 모든 관계 조회
|
||||||
const originalRelationships = await prisma.table_relationships.findMany({
|
const originalRelationships = await query<{
|
||||||
where: {
|
relationship_id: number;
|
||||||
company_code: companyCode,
|
diagram_id: number;
|
||||||
relationship_name: originalDiagramName,
|
relationship_name: string;
|
||||||
is_active: "Y",
|
from_table_name: string;
|
||||||
},
|
from_column_name: string;
|
||||||
});
|
to_table_name: string;
|
||||||
|
to_column_name: string;
|
||||||
|
relationship_type: string;
|
||||||
|
connection_type: string;
|
||||||
|
settings: any;
|
||||||
|
company_code: string;
|
||||||
|
created_by: string;
|
||||||
|
updated_by: string;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM table_relationships
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND relationship_name = $2
|
||||||
|
AND is_active = 'Y'`,
|
||||||
|
[companyCode, originalDiagramName]
|
||||||
|
);
|
||||||
|
|
||||||
if (originalRelationships.length === 0) {
|
if (originalRelationships.length === 0) {
|
||||||
throw new Error("복사할 관계도를 찾을 수 없습니다.");
|
throw new Error("복사할 관계도를 찾을 수 없습니다.");
|
||||||
|
|
@ -933,13 +978,14 @@ export class DataflowService {
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const existingDiagram = await prisma.table_relationships.findFirst({
|
const existingDiagram = await queryOne(
|
||||||
where: {
|
`SELECT relationship_id FROM table_relationships
|
||||||
company_code: companyCode,
|
WHERE company_code = $1
|
||||||
relationship_name: newDiagramName,
|
AND relationship_name = $2
|
||||||
is_active: "Y",
|
AND is_active = 'Y'
|
||||||
},
|
LIMIT 1`,
|
||||||
});
|
[companyCode, newDiagramName]
|
||||||
|
);
|
||||||
|
|
||||||
if (!existingDiagram) {
|
if (!existingDiagram) {
|
||||||
break;
|
break;
|
||||||
|
|
@ -950,43 +996,52 @@ export class DataflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 새로운 diagram_id 생성
|
// 새로운 diagram_id 생성
|
||||||
const maxDiagramId = await prisma.table_relationships.findFirst({
|
const maxDiagramId = await queryOne<{ diagram_id: number }>(
|
||||||
where: {
|
`SELECT diagram_id FROM table_relationships
|
||||||
company_code: companyCode,
|
WHERE company_code = $1
|
||||||
},
|
ORDER BY diagram_id DESC
|
||||||
orderBy: {
|
LIMIT 1`,
|
||||||
diagram_id: "desc",
|
[companyCode]
|
||||||
},
|
);
|
||||||
select: {
|
|
||||||
diagram_id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const newDiagramId = (maxDiagramId?.diagram_id || 0) + 1;
|
const newDiagramId = (maxDiagramId?.diagram_id || 0) + 1;
|
||||||
|
|
||||||
// 트랜잭션으로 모든 관계 복사
|
// 트랜잭션으로 모든 관계 복사
|
||||||
const copiedRelationships = await prisma.$transaction(
|
const copiedRelationships = await transaction(async (client) => {
|
||||||
originalRelationships.map((rel) =>
|
const results: any[] = [];
|
||||||
prisma.table_relationships.create({
|
|
||||||
data: {
|
for (const rel of originalRelationships) {
|
||||||
diagram_id: newDiagramId,
|
const result = await client.query(
|
||||||
relationship_name: newDiagramName,
|
`INSERT INTO table_relationships (
|
||||||
from_table_name: rel.from_table_name,
|
diagram_id, relationship_name, from_table_name, from_column_name,
|
||||||
from_column_name: rel.from_column_name,
|
to_table_name, to_column_name, relationship_type, connection_type,
|
||||||
to_table_name: rel.to_table_name,
|
settings, company_code, is_active, created_by, updated_by, created_at, updated_at
|
||||||
to_column_name: rel.to_column_name,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'Y', $11, $12, now(), now())
|
||||||
relationship_type: rel.relationship_type,
|
RETURNING *`,
|
||||||
connection_type: rel.connection_type,
|
[
|
||||||
settings: rel.settings as any,
|
newDiagramId,
|
||||||
company_code: rel.company_code,
|
newDiagramName,
|
||||||
is_active: "Y",
|
rel.from_table_name,
|
||||||
created_by: rel.created_by,
|
rel.from_column_name,
|
||||||
updated_by: rel.updated_by,
|
rel.to_table_name,
|
||||||
},
|
rel.to_column_name,
|
||||||
})
|
rel.relationship_type,
|
||||||
)
|
rel.connection_type,
|
||||||
|
rel.settings,
|
||||||
|
rel.company_code,
|
||||||
|
rel.created_by,
|
||||||
|
rel.updated_by,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result.rows && result.rows.length > 0) {
|
||||||
|
results.push(result.rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: 관계도 복사 완료 - ${originalDiagramName} → ${newDiagramName} (diagram_id: ${newDiagramId}), ${copiedRelationships.length}개 관계 복사`
|
`DataflowService: 관계도 복사 완료 - ${originalDiagramName} → ${newDiagramName} (diagram_id: ${newDiagramId}), ${copiedRelationships.length}개 관계 복사`
|
||||||
);
|
);
|
||||||
|
|
@ -1012,18 +1067,20 @@ export class DataflowService {
|
||||||
logger.info(`DataflowService: 관계도 삭제 시작 - ${diagramName}`);
|
logger.info(`DataflowService: 관계도 삭제 시작 - ${diagramName}`);
|
||||||
|
|
||||||
// 관계도의 모든 관계 삭제 (하드 삭제)
|
// 관계도의 모든 관계 삭제 (하드 삭제)
|
||||||
const deleteResult = await prisma.table_relationships.deleteMany({
|
const deleteResult = await query<{ count: number }>(
|
||||||
where: {
|
`DELETE FROM table_relationships
|
||||||
company_code: companyCode,
|
WHERE company_code = $1 AND relationship_name = $2
|
||||||
relationship_name: diagramName,
|
RETURNING relationship_id`,
|
||||||
},
|
[companyCode, diagramName]
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`DataflowService: 관계도 삭제 완료 - ${diagramName}, ${deleteResult.count}개 관계 삭제`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return deleteResult.count;
|
const count = deleteResult.length;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`DataflowService: 관계도 삭제 완료 - ${diagramName}, ${count}개 관계 삭제`
|
||||||
|
);
|
||||||
|
|
||||||
|
return count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`DataflowService: 관계도 삭제 실패 - ${diagramName}`, error);
|
logger.error(`DataflowService: 관계도 삭제 실패 - ${diagramName}`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -1043,20 +1100,20 @@ export class DataflowService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// diagram_id로 모든 관계 조회
|
// diagram_id로 모든 관계 조회
|
||||||
const relationships = await prisma.table_relationships.findMany({
|
const relationships = await query(
|
||||||
where: {
|
`SELECT * FROM table_relationships
|
||||||
diagram_id: diagramId,
|
WHERE diagram_id = $1
|
||||||
company_code: companyCode,
|
AND company_code = $2
|
||||||
is_active: "Y",
|
AND is_active = 'Y'
|
||||||
},
|
ORDER BY relationship_id ASC`,
|
||||||
orderBy: [{ relationship_id: "asc" }],
|
[diagramId, companyCode]
|
||||||
});
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`DataflowService: diagram_id로 관계도 관계 조회 완료 - ${relationships.length}개 관계`
|
`DataflowService: diagram_id로 관계도 관계 조회 완료 - ${relationships.length}개 관계`
|
||||||
);
|
);
|
||||||
|
|
||||||
return relationships.map((rel) => ({
|
return relationships.map((rel: any) => ({
|
||||||
...rel,
|
...rel,
|
||||||
settings: rel.settings as any,
|
settings: rel.settings as any,
|
||||||
}));
|
}));
|
||||||
|
|
@ -1082,16 +1139,14 @@ export class DataflowService {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 먼저 해당 relationship_id의 diagram_id를 찾음
|
// 먼저 해당 relationship_id의 diagram_id를 찾음
|
||||||
const targetRelationship = await prisma.table_relationships.findFirst({
|
const targetRelationship = await queryOne<{ diagram_id: number }>(
|
||||||
where: {
|
`SELECT diagram_id FROM table_relationships
|
||||||
relationship_id: relationshipId,
|
WHERE relationship_id = $1
|
||||||
company_code: companyCode,
|
AND company_code = $2
|
||||||
is_active: "Y",
|
AND is_active = 'Y'
|
||||||
},
|
LIMIT 1`,
|
||||||
select: {
|
[relationshipId, companyCode]
|
||||||
diagram_id: true,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetRelationship) {
|
if (!targetRelationship) {
|
||||||
throw new Error("해당 관계 ID를 찾을 수 없습니다.");
|
throw new Error("해당 관계 ID를 찾을 수 없습니다.");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export interface DbTypeCategory {
|
export interface DbTypeCategory {
|
||||||
type_code: string;
|
type_code: string;
|
||||||
|
|
@ -42,25 +40,24 @@ export class DbTypeCategoryService {
|
||||||
*/
|
*/
|
||||||
static async getAllCategories(): Promise<ApiResponse<DbTypeCategory[]>> {
|
static async getAllCategories(): Promise<ApiResponse<DbTypeCategory[]>> {
|
||||||
try {
|
try {
|
||||||
const categories = await prisma.db_type_categories.findMany({
|
const categories = await query<DbTypeCategory>(
|
||||||
where: { is_active: true },
|
`SELECT * FROM db_type_categories
|
||||||
orderBy: [
|
WHERE is_active = $1
|
||||||
{ sort_order: 'asc' },
|
ORDER BY sort_order ASC, display_name ASC`,
|
||||||
{ display_name: 'asc' }
|
[true]
|
||||||
]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: categories,
|
data: categories,
|
||||||
message: "DB 타입 카테고리 목록을 조회했습니다."
|
message: "DB 타입 카테고리 목록을 조회했습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,30 +65,33 @@ export class DbTypeCategoryService {
|
||||||
/**
|
/**
|
||||||
* 특정 DB 타입 카테고리 조회
|
* 특정 DB 타입 카테고리 조회
|
||||||
*/
|
*/
|
||||||
static async getCategoryByTypeCode(typeCode: string): Promise<ApiResponse<DbTypeCategory>> {
|
static async getCategoryByTypeCode(
|
||||||
|
typeCode: string
|
||||||
|
): Promise<ApiResponse<DbTypeCategory>> {
|
||||||
try {
|
try {
|
||||||
const category = await prisma.db_type_categories.findUnique({
|
const category = await queryOne<DbTypeCategory>(
|
||||||
where: { type_code: typeCode }
|
`SELECT * FROM db_type_categories WHERE type_code = $1`,
|
||||||
});
|
[typeCode]
|
||||||
|
);
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "해당 DB 타입 카테고리를 찾을 수 없습니다."
|
message: "해당 DB 타입 카테고리를 찾을 수 없습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: category,
|
data: category,
|
||||||
message: "DB 타입 카테고리를 조회했습니다."
|
message: "DB 타입 카테고리를 조회했습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입 카테고리 조회 오류:", error);
|
console.error("DB 타입 카테고리 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
message: "DB 타입 카테고리 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,41 +99,49 @@ export class DbTypeCategoryService {
|
||||||
/**
|
/**
|
||||||
* DB 타입 카테고리 생성
|
* DB 타입 카테고리 생성
|
||||||
*/
|
*/
|
||||||
static async createCategory(data: CreateDbTypeCategoryRequest): Promise<ApiResponse<DbTypeCategory>> {
|
static async createCategory(
|
||||||
|
data: CreateDbTypeCategoryRequest
|
||||||
|
): Promise<ApiResponse<DbTypeCategory>> {
|
||||||
try {
|
try {
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
const existing = await prisma.db_type_categories.findUnique({
|
const existing = await queryOne<DbTypeCategory>(
|
||||||
where: { type_code: data.type_code }
|
`SELECT * FROM db_type_categories WHERE type_code = $1`,
|
||||||
});
|
[data.type_code]
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "이미 존재하는 DB 타입 코드입니다."
|
message: "이미 존재하는 DB 타입 코드입니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await prisma.db_type_categories.create({
|
const category = await queryOne<DbTypeCategory>(
|
||||||
data: {
|
`INSERT INTO db_type_categories
|
||||||
type_code: data.type_code,
|
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||||
display_name: data.display_name,
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
icon: data.icon,
|
RETURNING *`,
|
||||||
color: data.color,
|
[
|
||||||
sort_order: data.sort_order || 0
|
data.type_code,
|
||||||
}
|
data.display_name,
|
||||||
});
|
data.icon || null,
|
||||||
|
data.color || null,
|
||||||
|
data.sort_order || 0,
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: category,
|
data: category || undefined,
|
||||||
message: "DB 타입 카테고리가 생성되었습니다."
|
message: "DB 타입 카테고리가 생성되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입 카테고리 생성 오류:", error);
|
console.error("DB 타입 카테고리 생성 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입 카테고리 생성 중 오류가 발생했습니다.",
|
message: "DB 타입 카테고리 생성 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,31 +149,56 @@ export class DbTypeCategoryService {
|
||||||
/**
|
/**
|
||||||
* DB 타입 카테고리 수정
|
* DB 타입 카테고리 수정
|
||||||
*/
|
*/
|
||||||
static async updateCategory(typeCode: string, data: UpdateDbTypeCategoryRequest): Promise<ApiResponse<DbTypeCategory>> {
|
static async updateCategory(
|
||||||
|
typeCode: string,
|
||||||
|
data: UpdateDbTypeCategoryRequest
|
||||||
|
): Promise<ApiResponse<DbTypeCategory>> {
|
||||||
try {
|
try {
|
||||||
const category = await prisma.db_type_categories.update({
|
// 동적 UPDATE 쿼리 생성
|
||||||
where: { type_code: typeCode },
|
const updateFields: string[] = ["updated_at = NOW()"];
|
||||||
data: {
|
const values: any[] = [];
|
||||||
display_name: data.display_name,
|
let paramIndex = 1;
|
||||||
icon: data.icon,
|
|
||||||
color: data.color,
|
if (data.display_name !== undefined) {
|
||||||
sort_order: data.sort_order,
|
updateFields.push(`display_name = $${paramIndex++}`);
|
||||||
is_active: data.is_active,
|
values.push(data.display_name);
|
||||||
updated_at: new Date()
|
|
||||||
}
|
}
|
||||||
});
|
if (data.icon !== undefined) {
|
||||||
|
updateFields.push(`icon = $${paramIndex++}`);
|
||||||
|
values.push(data.icon);
|
||||||
|
}
|
||||||
|
if (data.color !== undefined) {
|
||||||
|
updateFields.push(`color = $${paramIndex++}`);
|
||||||
|
values.push(data.color);
|
||||||
|
}
|
||||||
|
if (data.sort_order !== undefined) {
|
||||||
|
updateFields.push(`sort_order = $${paramIndex++}`);
|
||||||
|
values.push(data.sort_order);
|
||||||
|
}
|
||||||
|
if (data.is_active !== undefined) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
values.push(data.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await queryOne<DbTypeCategory>(
|
||||||
|
`UPDATE db_type_categories
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE type_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, typeCode]
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: category,
|
data: category || undefined,
|
||||||
message: "DB 타입 카테고리가 수정되었습니다."
|
message: "DB 타입 카테고리가 수정되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입 카테고리 수정 오류:", error);
|
console.error("DB 타입 카테고리 수정 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입 카테고리 수정 중 오류가 발생했습니다.",
|
message: "DB 타입 카테고리 수정 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,38 +209,37 @@ export class DbTypeCategoryService {
|
||||||
static async deleteCategory(typeCode: string): Promise<ApiResponse<void>> {
|
static async deleteCategory(typeCode: string): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
// 해당 타입을 사용하는 연결이 있는지 확인
|
// 해당 타입을 사용하는 연결이 있는지 확인
|
||||||
const connectionsCount = await prisma.external_db_connections.count({
|
const countResult = await queryOne<{ count: string }>(
|
||||||
where: {
|
`SELECT COUNT(*) as count FROM external_db_connections
|
||||||
db_type: typeCode,
|
WHERE db_type = $1 AND is_active = $2`,
|
||||||
is_active: "Y"
|
[typeCode, "Y"]
|
||||||
}
|
);
|
||||||
});
|
const connectionsCount = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
if (connectionsCount > 0) {
|
if (connectionsCount > 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `해당 DB 타입을 사용하는 연결이 ${connectionsCount}개 있어 삭제할 수 없습니다.`
|
message: `해당 DB 타입을 사용하는 연결이 ${connectionsCount}개 있어 삭제할 수 없습니다.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.db_type_categories.update({
|
await query(
|
||||||
where: { type_code: typeCode },
|
`UPDATE db_type_categories
|
||||||
data: {
|
SET is_active = $1, updated_at = NOW()
|
||||||
is_active: false,
|
WHERE type_code = $2`,
|
||||||
updated_at: new Date()
|
[false, typeCode]
|
||||||
}
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "DB 타입 카테고리가 삭제되었습니다."
|
message: "DB 타입 카테고리가 삭제되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입 카테고리 삭제 오류:", error);
|
console.error("DB 타입 카테고리 삭제 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입 카테고리 삭제 중 오류가 발생했습니다.",
|
message: "DB 타입 카테고리 삭제 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,38 +249,36 @@ export class DbTypeCategoryService {
|
||||||
*/
|
*/
|
||||||
static async getConnectionStatsByType(): Promise<ApiResponse<any[]>> {
|
static async getConnectionStatsByType(): Promise<ApiResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
const stats = await prisma.external_db_connections.groupBy({
|
// LEFT JOIN으로 한 번에 조회
|
||||||
by: ['db_type'],
|
const result = await query<any>(
|
||||||
where: { is_active: "Y" },
|
`SELECT
|
||||||
_count: {
|
c.*,
|
||||||
id: true
|
COUNT(e.id) as connection_count
|
||||||
}
|
FROM db_type_categories c
|
||||||
});
|
LEFT JOIN external_db_connections e ON c.type_code = e.db_type AND e.is_active = $1
|
||||||
|
WHERE c.is_active = $2
|
||||||
|
GROUP BY c.type_code, c.display_name, c.icon, c.color, c.sort_order, c.is_active, c.created_at, c.updated_at
|
||||||
|
ORDER BY c.sort_order ASC`,
|
||||||
|
["Y", true]
|
||||||
|
);
|
||||||
|
|
||||||
// 카테고리 정보와 함께 반환
|
// connection_count를 숫자로 변환
|
||||||
const categories = await prisma.db_type_categories.findMany({
|
const formattedResult = result.map((row) => ({
|
||||||
where: { is_active: true }
|
...row,
|
||||||
});
|
connection_count: parseInt(row.connection_count),
|
||||||
|
}));
|
||||||
const result = categories.map(category => {
|
|
||||||
const stat = stats.find(s => s.db_type === category.type_code);
|
|
||||||
return {
|
|
||||||
...category,
|
|
||||||
connection_count: stat?._count.id || 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: formattedResult,
|
||||||
message: "DB 타입별 연결 통계를 조회했습니다."
|
message: "DB 타입별 연결 통계를 조회했습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("DB 타입별 통계 조회 오류:", error);
|
console.error("DB 타입별 통계 조회 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "DB 타입별 통계 조회 중 오류가 발생했습니다.",
|
message: "DB 타입별 통계 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,60 +290,69 @@ export class DbTypeCategoryService {
|
||||||
try {
|
try {
|
||||||
const defaultCategories = [
|
const defaultCategories = [
|
||||||
{
|
{
|
||||||
type_code: 'postgresql',
|
type_code: "postgresql",
|
||||||
display_name: 'PostgreSQL',
|
display_name: "PostgreSQL",
|
||||||
icon: 'postgresql',
|
icon: "postgresql",
|
||||||
color: '#336791',
|
color: "#336791",
|
||||||
sort_order: 1
|
sort_order: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type_code: 'oracle',
|
type_code: "oracle",
|
||||||
display_name: 'Oracle',
|
display_name: "Oracle",
|
||||||
icon: 'oracle',
|
icon: "oracle",
|
||||||
color: '#F80000',
|
color: "#F80000",
|
||||||
sort_order: 2
|
sort_order: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type_code: 'mysql',
|
type_code: "mysql",
|
||||||
display_name: 'MySQL',
|
display_name: "MySQL",
|
||||||
icon: 'mysql',
|
icon: "mysql",
|
||||||
color: '#4479A1',
|
color: "#4479A1",
|
||||||
sort_order: 3
|
sort_order: 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type_code: 'mariadb',
|
type_code: "mariadb",
|
||||||
display_name: 'MariaDB',
|
display_name: "MariaDB",
|
||||||
icon: 'mariadb',
|
icon: "mariadb",
|
||||||
color: '#003545',
|
color: "#003545",
|
||||||
sort_order: 4
|
sort_order: 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type_code: 'mssql',
|
type_code: "mssql",
|
||||||
display_name: 'SQL Server',
|
display_name: "SQL Server",
|
||||||
icon: 'mssql',
|
icon: "mssql",
|
||||||
color: '#CC2927',
|
color: "#CC2927",
|
||||||
sort_order: 5
|
sort_order: 5,
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const category of defaultCategories) {
|
for (const category of defaultCategories) {
|
||||||
await prisma.db_type_categories.upsert({
|
await query(
|
||||||
where: { type_code: category.type_code },
|
`INSERT INTO db_type_categories
|
||||||
update: {},
|
(type_code, display_name, icon, color, sort_order, is_active, created_at, updated_at)
|
||||||
create: category
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||||
});
|
ON CONFLICT (type_code) DO NOTHING`,
|
||||||
|
[
|
||||||
|
category.type_code,
|
||||||
|
category.display_name,
|
||||||
|
category.icon,
|
||||||
|
category.color,
|
||||||
|
category.sort_order,
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "기본 DB 타입 카테고리가 초기화되었습니다."
|
message: "기본 DB 타입 카테고리가 초기화되었습니다.",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("기본 카테고리 초기화 오류:", error);
|
console.error("기본 카테고리 초기화 오류:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "기본 카테고리 초기화 중 오류가 발생했습니다.",
|
message: "기본 카테고리 초기화 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,9 @@
|
||||||
* 모든 DDL 실행을 추적하고 기록
|
* 모든 DDL 실행을 추적하고 기록
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class DDLAuditLogger {
|
export class DDLAuditLogger {
|
||||||
/**
|
/**
|
||||||
* DDL 실행 로그 기록
|
* DDL 실행 로그 기록
|
||||||
|
|
@ -24,8 +22,8 @@ export class DDLAuditLogger {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// DDL 실행 로그 데이터베이스에 저장
|
// DDL 실행 로그 데이터베이스에 저장
|
||||||
const logEntry = await prisma.$executeRaw`
|
await query(
|
||||||
INSERT INTO ddl_execution_log (
|
`INSERT INTO ddl_execution_log (
|
||||||
user_id,
|
user_id,
|
||||||
company_code,
|
company_code,
|
||||||
ddl_type,
|
ddl_type,
|
||||||
|
|
@ -34,17 +32,17 @@ export class DDLAuditLogger {
|
||||||
success,
|
success,
|
||||||
error_message,
|
error_message,
|
||||||
executed_at
|
executed_at
|
||||||
) VALUES (
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
|
||||||
${userId},
|
[
|
||||||
${companyCode},
|
userId,
|
||||||
${ddlType},
|
companyCode,
|
||||||
${tableName},
|
ddlType,
|
||||||
${ddlQuery},
|
tableName,
|
||||||
${success},
|
ddlQuery,
|
||||||
${error || null},
|
success,
|
||||||
NOW()
|
error || null,
|
||||||
)
|
]
|
||||||
`;
|
);
|
||||||
|
|
||||||
// 추가 로깅 (파일 로그)
|
// 추가 로깅 (파일 로그)
|
||||||
const logData = {
|
const logData = {
|
||||||
|
|
@ -137,7 +135,7 @@ export class DDLAuditLogger {
|
||||||
params.push(ddlType);
|
params.push(ddlType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
user_id,
|
user_id,
|
||||||
|
|
@ -159,8 +157,8 @@ export class DDLAuditLogger {
|
||||||
|
|
||||||
params.push(limit);
|
params.push(limit);
|
||||||
|
|
||||||
const logs = await prisma.$queryRawUnsafe(query, ...params);
|
const logs = await query<any>(sql, params);
|
||||||
return logs as any[];
|
return logs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("DDL 로그 조회 실패:", error);
|
logger.error("DDL 로그 조회 실패:", error);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -196,47 +194,40 @@ export class DDLAuditLogger {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전체 통계
|
// 전체 통계
|
||||||
const totalStats = (await prisma.$queryRawUnsafe(
|
const totalStats = await query<any>(
|
||||||
`
|
`SELECT
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_executions,
|
COUNT(*) as total_executions,
|
||||||
SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions,
|
SUM(CASE WHEN success = true THEN 1 ELSE 0 END) as successful_executions,
|
||||||
SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions
|
SUM(CASE WHEN success = false THEN 1 ELSE 0 END) as failed_executions
|
||||||
FROM ddl_execution_log
|
FROM ddl_execution_log
|
||||||
WHERE 1=1 ${dateFilter}
|
WHERE 1=1 ${dateFilter}`,
|
||||||
`,
|
params
|
||||||
...params
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
// DDL 타입별 통계
|
// DDL 타입별 통계
|
||||||
const ddlTypeStats = (await prisma.$queryRawUnsafe(
|
const ddlTypeStats = await query<any>(
|
||||||
`
|
`SELECT ddl_type, COUNT(*) as count
|
||||||
SELECT ddl_type, COUNT(*) as count
|
|
||||||
FROM ddl_execution_log
|
FROM ddl_execution_log
|
||||||
WHERE 1=1 ${dateFilter}
|
WHERE 1=1 ${dateFilter}
|
||||||
GROUP BY ddl_type
|
GROUP BY ddl_type
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC`,
|
||||||
`,
|
params
|
||||||
...params
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
// 사용자별 통계
|
// 사용자별 통계
|
||||||
const userStats = (await prisma.$queryRawUnsafe(
|
const userStats = await query<any>(
|
||||||
`
|
`SELECT user_id, COUNT(*) as count
|
||||||
SELECT user_id, COUNT(*) as count
|
|
||||||
FROM ddl_execution_log
|
FROM ddl_execution_log
|
||||||
WHERE 1=1 ${dateFilter}
|
WHERE 1=1 ${dateFilter}
|
||||||
GROUP BY user_id
|
GROUP BY user_id
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 10
|
LIMIT 10`,
|
||||||
`,
|
params
|
||||||
...params
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
// 최근 실패 로그
|
// 최근 실패 로그
|
||||||
const recentFailures = (await prisma.$queryRawUnsafe(
|
const recentFailures = await query<any>(
|
||||||
`
|
`SELECT
|
||||||
SELECT
|
|
||||||
user_id,
|
user_id,
|
||||||
ddl_type,
|
ddl_type,
|
||||||
table_name,
|
table_name,
|
||||||
|
|
@ -245,10 +236,9 @@ export class DDLAuditLogger {
|
||||||
FROM ddl_execution_log
|
FROM ddl_execution_log
|
||||||
WHERE success = false ${dateFilter}
|
WHERE success = false ${dateFilter}
|
||||||
ORDER BY executed_at DESC
|
ORDER BY executed_at DESC
|
||||||
LIMIT 10
|
LIMIT 10`,
|
||||||
`,
|
params
|
||||||
...params
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
const stats = totalStats[0];
|
const stats = totalStats[0];
|
||||||
|
|
||||||
|
|
@ -284,9 +274,8 @@ export class DDLAuditLogger {
|
||||||
*/
|
*/
|
||||||
static async getTableDDLHistory(tableName: string): Promise<any[]> {
|
static async getTableDDLHistory(tableName: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
const history = await prisma.$queryRawUnsafe(
|
const history = await query<any>(
|
||||||
`
|
`SELECT
|
||||||
SELECT
|
|
||||||
id,
|
id,
|
||||||
user_id,
|
user_id,
|
||||||
ddl_type,
|
ddl_type,
|
||||||
|
|
@ -297,12 +286,11 @@ export class DDLAuditLogger {
|
||||||
FROM ddl_execution_log
|
FROM ddl_execution_log
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
ORDER BY executed_at DESC
|
ORDER BY executed_at DESC
|
||||||
LIMIT 20
|
LIMIT 20`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return history as any[];
|
return history;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error);
|
logger.error(`테이블 '${tableName}' DDL 히스토리 조회 실패:`, error);
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -317,17 +305,20 @@ export class DDLAuditLogger {
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||||
|
|
||||||
const result = await prisma.$executeRaw`
|
const result = await query(
|
||||||
DELETE FROM ddl_execution_log
|
`DELETE FROM ddl_execution_log
|
||||||
WHERE executed_at < ${cutoffDate}
|
WHERE executed_at < $1`,
|
||||||
`;
|
[cutoffDate]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`DDL 로그 정리 완료: ${result}개 레코드 삭제`, {
|
const deletedCount = result.length;
|
||||||
|
|
||||||
|
logger.info(`DDL 로그 정리 완료: ${deletedCount}개 레코드 삭제`, {
|
||||||
retentionDays,
|
retentionDays,
|
||||||
cutoffDate: cutoffDate.toISOString(),
|
cutoffDate: cutoffDate.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return result as number;
|
return deletedCount;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("DDL 로그 정리 실패:", error);
|
logger.error("DDL 로그 정리 실패:", error);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* 실제 PostgreSQL 테이블 및 컬럼 생성을 담당
|
* 실제 PostgreSQL 테이블 및 컬럼 생성을 담당
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
import {
|
import {
|
||||||
CreateColumnDefinition,
|
CreateColumnDefinition,
|
||||||
DDLExecutionResult,
|
DDLExecutionResult,
|
||||||
|
|
@ -15,8 +15,6 @@ import { DDLAuditLogger } from "./ddlAuditLogger";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { cache, CacheKeys } from "../utils/cache";
|
import { cache, CacheKeys } from "../utils/cache";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export class DDLExecutionService {
|
export class DDLExecutionService {
|
||||||
/**
|
/**
|
||||||
* 새 테이블 생성
|
* 새 테이블 생성
|
||||||
|
|
@ -98,15 +96,15 @@ export class DDLExecutionService {
|
||||||
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
|
const ddlQuery = this.generateCreateTableQuery(tableName, columns);
|
||||||
|
|
||||||
// 5. 트랜잭션으로 안전하게 실행
|
// 5. 트랜잭션으로 안전하게 실행
|
||||||
await prisma.$transaction(async (tx) => {
|
await transaction(async (client) => {
|
||||||
// 5-1. 테이블 생성
|
// 5-1. 테이블 생성
|
||||||
await tx.$executeRawUnsafe(ddlQuery);
|
await client.query(ddlQuery);
|
||||||
|
|
||||||
// 5-2. 테이블 메타데이터 저장
|
// 5-2. 테이블 메타데이터 저장
|
||||||
await this.saveTableMetadata(tx, tableName, description);
|
await this.saveTableMetadata(client, tableName, description);
|
||||||
|
|
||||||
// 5-3. 컬럼 메타데이터 저장
|
// 5-3. 컬럼 메타데이터 저장
|
||||||
await this.saveColumnMetadata(tx, tableName, columns);
|
await this.saveColumnMetadata(client, tableName, columns);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. 성공 로그 기록
|
// 6. 성공 로그 기록
|
||||||
|
|
@ -269,12 +267,12 @@ export class DDLExecutionService {
|
||||||
const ddlQuery = this.generateAddColumnQuery(tableName, column);
|
const ddlQuery = this.generateAddColumnQuery(tableName, column);
|
||||||
|
|
||||||
// 6. 트랜잭션으로 안전하게 실행
|
// 6. 트랜잭션으로 안전하게 실행
|
||||||
await prisma.$transaction(async (tx) => {
|
await transaction(async (client) => {
|
||||||
// 6-1. 컬럼 추가
|
// 6-1. 컬럼 추가
|
||||||
await tx.$executeRawUnsafe(ddlQuery);
|
await client.query(ddlQuery);
|
||||||
|
|
||||||
// 6-2. 컬럼 메타데이터 저장
|
// 6-2. 컬럼 메타데이터 저장
|
||||||
await this.saveColumnMetadata(tx, tableName, [column]);
|
await this.saveColumnMetadata(client, tableName, [column]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 7. 성공 로그 기록
|
// 7. 성공 로그 기록
|
||||||
|
|
@ -424,51 +422,42 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
* 테이블 메타데이터 저장
|
* 테이블 메타데이터 저장
|
||||||
*/
|
*/
|
||||||
private async saveTableMetadata(
|
private async saveTableMetadata(
|
||||||
tx: any,
|
client: any,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
description?: string
|
description?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await tx.table_labels.upsert({
|
await client.query(
|
||||||
where: { table_name: tableName },
|
`
|
||||||
update: {
|
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
table_label: tableName,
|
VALUES ($1, $2, $3, now(), now())
|
||||||
description: description || `사용자 생성 테이블: ${tableName}`,
|
ON CONFLICT (table_name)
|
||||||
updated_date: new Date(),
|
DO UPDATE SET
|
||||||
},
|
table_label = $2,
|
||||||
create: {
|
description = $3,
|
||||||
table_name: tableName,
|
updated_date = now()
|
||||||
table_label: tableName,
|
`,
|
||||||
description: description || `사용자 생성 테이블: ${tableName}`,
|
[tableName, tableName, description || `사용자 생성 테이블: ${tableName}`]
|
||||||
created_date: new Date(),
|
);
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컬럼 메타데이터 저장
|
* 컬럼 메타데이터 저장
|
||||||
*/
|
*/
|
||||||
private async saveColumnMetadata(
|
private async saveColumnMetadata(
|
||||||
tx: any,
|
client: any,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
columns: CreateColumnDefinition[]
|
columns: CreateColumnDefinition[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
|
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
|
||||||
await tx.table_labels.upsert({
|
await client.query(
|
||||||
where: {
|
`
|
||||||
table_name: tableName,
|
INSERT INTO table_labels (table_name, table_label, description, created_date, updated_date)
|
||||||
},
|
VALUES ($1, $2, $3, now(), now())
|
||||||
update: {
|
ON CONFLICT (table_name)
|
||||||
updated_date: new Date(),
|
DO UPDATE SET updated_date = now()
|
||||||
},
|
`,
|
||||||
create: {
|
[tableName, tableName, `자동 생성된 테이블 메타데이터: ${tableName}`]
|
||||||
table_name: tableName,
|
);
|
||||||
table_label: tableName,
|
|
||||||
description: `자동 생성된 테이블 메타데이터: ${tableName}`,
|
|
||||||
created_date: new Date(),
|
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
|
// 기본 컬럼들 정의 (모든 테이블에 자동으로 추가되는 시스템 컬럼)
|
||||||
const defaultColumns = [
|
const defaultColumns = [
|
||||||
|
|
@ -516,20 +505,23 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
|
|
||||||
// 기본 컬럼들을 table_type_columns에 등록
|
// 기본 컬럼들을 table_type_columns에 등록
|
||||||
for (const defaultCol of defaultColumns) {
|
for (const defaultCol of defaultColumns) {
|
||||||
await tx.$executeRaw`
|
await client.query(
|
||||||
|
`
|
||||||
INSERT INTO table_type_columns (
|
INSERT INTO table_type_columns (
|
||||||
table_name, column_name, input_type, detail_settings,
|
table_name, column_name, input_type, detail_settings,
|
||||||
is_nullable, display_order, created_date, updated_date
|
is_nullable, display_order, created_date, updated_date
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${tableName}, ${defaultCol.name}, ${defaultCol.inputType}, '{}',
|
$1, $2, $3, '{}',
|
||||||
'Y', ${defaultCol.order}, now(), now()
|
'Y', $4, now(), now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (table_name, column_name)
|
ON CONFLICT (table_name, column_name)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
input_type = ${defaultCol.inputType},
|
input_type = $3,
|
||||||
display_order = ${defaultCol.order},
|
display_order = $4,
|
||||||
updated_date = now();
|
updated_date = now()
|
||||||
`;
|
`,
|
||||||
|
[tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 정의 컬럼들을 table_type_columns에 등록
|
// 사용자 정의 컬럼들을 table_type_columns에 등록
|
||||||
|
|
@ -538,89 +530,98 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
const inputType = this.convertWebTypeToInputType(
|
const inputType = this.convertWebTypeToInputType(
|
||||||
column.webType || "text"
|
column.webType || "text"
|
||||||
);
|
);
|
||||||
|
const detailSettings = JSON.stringify(column.detailSettings || {});
|
||||||
|
|
||||||
await tx.$executeRaw`
|
await client.query(
|
||||||
|
`
|
||||||
INSERT INTO table_type_columns (
|
INSERT INTO table_type_columns (
|
||||||
table_name, column_name, input_type, detail_settings,
|
table_name, column_name, input_type, detail_settings,
|
||||||
is_nullable, display_order, created_date, updated_date
|
is_nullable, display_order, created_date, updated_date
|
||||||
) VALUES (
|
) VALUES (
|
||||||
${tableName}, ${column.name}, ${inputType}, ${JSON.stringify(column.detailSettings || {})},
|
$1, $2, $3, $4,
|
||||||
'Y', ${i}, now(), now()
|
'Y', $5, now(), now()
|
||||||
)
|
)
|
||||||
ON CONFLICT (table_name, column_name)
|
ON CONFLICT (table_name, column_name)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
input_type = ${inputType},
|
input_type = $3,
|
||||||
detail_settings = ${JSON.stringify(column.detailSettings || {})},
|
detail_settings = $4,
|
||||||
display_order = ${i},
|
display_order = $5,
|
||||||
updated_date = now();
|
updated_date = now()
|
||||||
`;
|
`,
|
||||||
|
[tableName, column.name, inputType, detailSettings, i]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
|
// 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
|
||||||
// 1. 기본 컬럼들을 column_labels에 등록
|
// 1. 기본 컬럼들을 column_labels에 등록
|
||||||
for (const defaultCol of defaultColumns) {
|
for (const defaultCol of defaultColumns) {
|
||||||
await tx.column_labels.upsert({
|
await client.query(
|
||||||
where: {
|
`
|
||||||
table_name_column_name: {
|
INSERT INTO column_labels (
|
||||||
table_name: tableName,
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
column_name: defaultCol.name,
|
description, display_order, is_visible, created_date, updated_date
|
||||||
},
|
) VALUES (
|
||||||
},
|
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
|
||||||
update: {
|
)
|
||||||
column_label: defaultCol.label,
|
ON CONFLICT (table_name, column_name)
|
||||||
input_type: defaultCol.inputType,
|
DO UPDATE SET
|
||||||
detail_settings: JSON.stringify({}),
|
column_label = $3,
|
||||||
description: defaultCol.description,
|
input_type = $4,
|
||||||
display_order: defaultCol.order,
|
detail_settings = $5,
|
||||||
is_visible: defaultCol.isVisible,
|
description = $6,
|
||||||
updated_date: new Date(),
|
display_order = $7,
|
||||||
},
|
is_visible = $8,
|
||||||
create: {
|
updated_date = now()
|
||||||
table_name: tableName,
|
`,
|
||||||
column_name: defaultCol.name,
|
[
|
||||||
column_label: defaultCol.label,
|
tableName,
|
||||||
input_type: defaultCol.inputType,
|
defaultCol.name,
|
||||||
detail_settings: JSON.stringify({}),
|
defaultCol.label,
|
||||||
description: defaultCol.description,
|
defaultCol.inputType,
|
||||||
display_order: defaultCol.order,
|
JSON.stringify({}),
|
||||||
is_visible: defaultCol.isVisible,
|
defaultCol.description,
|
||||||
created_date: new Date(),
|
defaultCol.order,
|
||||||
updated_date: new Date(),
|
defaultCol.isVisible,
|
||||||
},
|
]
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 사용자 정의 컬럼들을 column_labels에 등록
|
// 2. 사용자 정의 컬럼들을 column_labels에 등록
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
await tx.column_labels.upsert({
|
const inputType = this.convertWebTypeToInputType(
|
||||||
where: {
|
column.webType || "text"
|
||||||
table_name_column_name: {
|
);
|
||||||
table_name: tableName,
|
const detailSettings = JSON.stringify(column.detailSettings || {});
|
||||||
column_name: column.name,
|
|
||||||
},
|
await client.query(
|
||||||
},
|
`
|
||||||
update: {
|
INSERT INTO column_labels (
|
||||||
column_label: column.label || column.name,
|
table_name, column_name, column_label, input_type, detail_settings,
|
||||||
input_type: this.convertWebTypeToInputType(column.webType || "text"),
|
description, display_order, is_visible, created_date, updated_date
|
||||||
detail_settings: JSON.stringify(column.detailSettings || {}),
|
) VALUES (
|
||||||
description: column.description,
|
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
|
||||||
display_order: column.order || 0,
|
)
|
||||||
is_visible: true,
|
ON CONFLICT (table_name, column_name)
|
||||||
updated_date: new Date(),
|
DO UPDATE SET
|
||||||
},
|
column_label = $3,
|
||||||
create: {
|
input_type = $4,
|
||||||
table_name: tableName,
|
detail_settings = $5,
|
||||||
column_name: column.name,
|
description = $6,
|
||||||
column_label: column.label || column.name,
|
display_order = $7,
|
||||||
input_type: this.convertWebTypeToInputType(column.webType || "text"),
|
is_visible = $8,
|
||||||
detail_settings: JSON.stringify(column.detailSettings || {}),
|
updated_date = now()
|
||||||
description: column.description,
|
`,
|
||||||
display_order: column.order || 0,
|
[
|
||||||
is_visible: true,
|
tableName,
|
||||||
created_date: new Date(),
|
column.name,
|
||||||
updated_date: new Date(),
|
column.label || column.name,
|
||||||
},
|
inputType,
|
||||||
});
|
detailSettings,
|
||||||
|
column.description,
|
||||||
|
column.order || 0,
|
||||||
|
true,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -679,18 +680,18 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
*/
|
*/
|
||||||
private async checkTableExists(tableName: string): Promise<boolean> {
|
private async checkTableExists(tableName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await queryOne<{ exists: boolean }>(
|
||||||
`
|
`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT FROM information_schema.tables
|
SELECT FROM information_schema.tables
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = $1
|
AND table_name = $1
|
||||||
);
|
)
|
||||||
`,
|
`,
|
||||||
tableName
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (result as any)[0]?.exists || false;
|
return result?.exists || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("테이블 존재 확인 오류:", error);
|
logger.error("테이블 존재 확인 오류:", error);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -705,20 +706,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
columnName: string
|
columnName: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await queryOne<{ exists: boolean }>(
|
||||||
`
|
`
|
||||||
SELECT EXISTS (
|
SELECT EXISTS (
|
||||||
SELECT FROM information_schema.columns
|
SELECT FROM information_schema.columns
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
AND table_name = $1
|
AND table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
);
|
)
|
||||||
`,
|
`,
|
||||||
tableName,
|
[tableName, columnName]
|
||||||
columnName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (result as any)[0]?.exists || false;
|
return result?.exists || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("컬럼 존재 확인 오류:", error);
|
logger.error("컬럼 존재 확인 오류:", error);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -734,15 +734,16 @@ CREATE TABLE "${tableName}" (${baseColumns},
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
// 테이블 정보 조회
|
// 테이블 정보 조회
|
||||||
const tableInfo = await prisma.table_labels.findUnique({
|
const tableInfo = await queryOne(
|
||||||
where: { table_name: tableName },
|
`SELECT * FROM table_labels WHERE table_name = $1`,
|
||||||
});
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
// 컬럼 정보 조회
|
// 컬럼 정보 조회
|
||||||
const columns = await prisma.column_labels.findMany({
|
const columns = await query(
|
||||||
where: { table_name: tableName },
|
`SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
|
||||||
orderBy: { display_order: "asc" },
|
[tableName]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!tableInfo) {
|
if (!tableInfo) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import prisma from "../config/database";
|
import { query, queryOne } from "../database/db";
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
|
|
||||||
|
|
@ -44,7 +43,7 @@ export interface TableColumn {
|
||||||
dataType: string;
|
dataType: string;
|
||||||
nullable: boolean;
|
nullable: boolean;
|
||||||
primaryKey: boolean;
|
primaryKey: boolean;
|
||||||
maxLength?: number;
|
maxLength?: number | null;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,14 +139,13 @@ export class DynamicFormService {
|
||||||
tableName: string
|
tableName: string
|
||||||
): Promise<Array<{ column_name: string; data_type: string }>> {
|
): Promise<Array<{ column_name: string; data_type: string }>> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRaw<
|
const result = await query<{ column_name: string; data_type: string }>(
|
||||||
Array<{ column_name: string; data_type: string }>
|
`SELECT column_name, data_type
|
||||||
>`
|
|
||||||
SELECT column_name, data_type
|
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = ${tableName}
|
WHERE table_name = $1
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'`,
|
||||||
`;
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -161,12 +159,13 @@ export class DynamicFormService {
|
||||||
*/
|
*/
|
||||||
private async getTableColumnNames(tableName: string): Promise<string[]> {
|
private async getTableColumnNames(tableName: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const result = (await prisma.$queryRawUnsafe(`
|
const result = await query<{ column_name: string }>(
|
||||||
SELECT column_name
|
`SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = '${tableName}'
|
WHERE table_name = $1
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'`,
|
||||||
`)) as any[];
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
return result.map((row) => row.column_name);
|
return result.map((row) => row.column_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -180,15 +179,16 @@ export class DynamicFormService {
|
||||||
*/
|
*/
|
||||||
async getTablePrimaryKeys(tableName: string): Promise<string[]> {
|
async getTablePrimaryKeys(tableName: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const result = (await prisma.$queryRawUnsafe(`
|
const result = await query<{ column_name: string }>(
|
||||||
SELECT kcu.column_name
|
`SELECT kcu.column_name
|
||||||
FROM information_schema.table_constraints tc
|
FROM information_schema.table_constraints tc
|
||||||
JOIN information_schema.key_column_usage kcu
|
JOIN information_schema.key_column_usage kcu
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
WHERE tc.table_name = '${tableName}'
|
WHERE tc.table_name = $1
|
||||||
AND tc.constraint_type = 'PRIMARY KEY'
|
AND tc.constraint_type = 'PRIMARY KEY'
|
||||||
AND tc.table_schema = 'public'
|
AND tc.table_schema = 'public'`,
|
||||||
`)) as any[];
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
return result.map((row) => row.column_name);
|
return result.map((row) => row.column_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -381,7 +381,7 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
console.log("📝 실행할 UPSERT SQL:", upsertQuery);
|
||||||
console.log("📊 SQL 파라미터:", values);
|
console.log("📊 SQL 파라미터:", values);
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(upsertQuery, ...values);
|
const result = await query<any>(upsertQuery, values);
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 저장 성공:", result);
|
||||||
|
|
||||||
|
|
@ -528,7 +528,7 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 부분 UPDATE SQL:", updateQuery);
|
console.log("📝 실행할 부분 UPDATE SQL:", updateQuery);
|
||||||
console.log("📊 SQL 파라미터:", values);
|
console.log("📊 SQL 파라미터:", values);
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(updateQuery, ...values);
|
const result = await query<any>(updateQuery, values);
|
||||||
|
|
||||||
console.log("✅ 서비스: 부분 업데이트 성공:", result);
|
console.log("✅ 서비스: 부분 업데이트 성공:", result);
|
||||||
|
|
||||||
|
|
@ -643,13 +643,14 @@ export class DynamicFormService {
|
||||||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||||||
|
|
||||||
// 기본키 데이터 타입 조회하여 적절한 캐스팅 적용
|
// 기본키 데이터 타입 조회하여 적절한 캐스팅 적용
|
||||||
const primaryKeyInfo = (await prisma.$queryRawUnsafe(`
|
const primaryKeyInfo = await query<{ data_type: string }>(
|
||||||
SELECT data_type
|
`SELECT data_type
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = '${tableName}'
|
WHERE table_name = $1
|
||||||
AND column_name = '${primaryKeyColumn}'
|
AND column_name = $2
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'`,
|
||||||
`)) as any[];
|
[tableName, primaryKeyColumn]
|
||||||
|
);
|
||||||
|
|
||||||
let typeCastSuffix = "";
|
let typeCastSuffix = "";
|
||||||
if (primaryKeyInfo.length > 0) {
|
if (primaryKeyInfo.length > 0) {
|
||||||
|
|
@ -678,7 +679,7 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 UPDATE SQL:", updateQuery);
|
console.log("📝 실행할 UPDATE SQL:", updateQuery);
|
||||||
console.log("📊 SQL 파라미터:", values);
|
console.log("📊 SQL 파라미터:", values);
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(updateQuery, ...values);
|
const result = await query<any>(updateQuery, values);
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 업데이트 성공:", result);
|
||||||
|
|
||||||
|
|
@ -760,20 +761,16 @@ export class DynamicFormService {
|
||||||
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
||||||
console.log("🔍 테이블명:", tableName);
|
console.log("🔍 테이블명:", tableName);
|
||||||
|
|
||||||
const primaryKeyResult = await prisma.$queryRawUnsafe(
|
const primaryKeyResult = await query<{
|
||||||
primaryKeyQuery,
|
column_name: string;
|
||||||
tableName
|
data_type: string;
|
||||||
);
|
}>(primaryKeyQuery, [tableName]);
|
||||||
|
|
||||||
if (
|
if (!primaryKeyResult || primaryKeyResult.length === 0) {
|
||||||
!primaryKeyResult ||
|
|
||||||
!Array.isArray(primaryKeyResult) ||
|
|
||||||
primaryKeyResult.length === 0
|
|
||||||
) {
|
|
||||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryKeyInfo = primaryKeyResult[0] as any;
|
const primaryKeyInfo = primaryKeyResult[0];
|
||||||
const primaryKeyColumn = primaryKeyInfo.column_name;
|
const primaryKeyColumn = primaryKeyInfo.column_name;
|
||||||
const primaryKeyDataType = primaryKeyInfo.data_type;
|
const primaryKeyDataType = primaryKeyInfo.data_type;
|
||||||
console.log("🔑 발견된 기본키:", {
|
console.log("🔑 발견된 기본키:", {
|
||||||
|
|
@ -810,7 +807,7 @@ export class DynamicFormService {
|
||||||
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
console.log("📝 실행할 DELETE SQL:", deleteQuery);
|
||||||
console.log("📊 SQL 파라미터:", [id]);
|
console.log("📊 SQL 파라미터:", [id]);
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe(deleteQuery, id);
|
const result = await query<any>(deleteQuery, [id]);
|
||||||
|
|
||||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||||
|
|
||||||
|
|
@ -864,9 +861,21 @@ export class DynamicFormService {
|
||||||
try {
|
try {
|
||||||
console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id });
|
console.log("📄 서비스: 폼 데이터 단건 조회 시작:", { id });
|
||||||
|
|
||||||
const result = await prisma.dynamic_form_data.findUnique({
|
const result = await queryOne<{
|
||||||
where: { id },
|
id: number;
|
||||||
});
|
screen_id: number;
|
||||||
|
table_name: string;
|
||||||
|
form_data: any;
|
||||||
|
created_at: Date | null;
|
||||||
|
updated_at: Date | null;
|
||||||
|
created_by: string;
|
||||||
|
updated_by: string;
|
||||||
|
}>(
|
||||||
|
`SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
|
||||||
|
FROM dynamic_form_data
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
console.log("❌ 서비스: 폼 데이터를 찾을 수 없음");
|
console.log("❌ 서비스: 폼 데이터를 찾을 수 없음");
|
||||||
|
|
@ -914,50 +923,62 @@ export class DynamicFormService {
|
||||||
sortBy = "created_at",
|
sortBy = "created_at",
|
||||||
sortOrder = "desc",
|
sortOrder = "desc",
|
||||||
} = params;
|
} = params;
|
||||||
const skip = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
// 검색 조건 구성
|
// 정렬 컬럼 검증 (SQL Injection 방지)
|
||||||
const where: Prisma.dynamic_form_dataWhereInput = {
|
const allowedSortColumns = ["created_at", "updated_at", "id"];
|
||||||
screen_id: screenId,
|
const validSortBy = allowedSortColumns.includes(sortBy)
|
||||||
};
|
? sortBy
|
||||||
|
: "created_at";
|
||||||
|
const validSortOrder = sortOrder === "asc" ? "ASC" : "DESC";
|
||||||
|
|
||||||
|
// 검색 조건 및 파라미터 구성
|
||||||
|
const queryParams: any[] = [screenId];
|
||||||
|
let searchCondition = "";
|
||||||
|
|
||||||
// 검색어가 있는 경우 form_data 필드에서 검색
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
searchCondition = ` AND (
|
||||||
{
|
form_data::text ILIKE $2
|
||||||
form_data: {
|
OR table_name ILIKE $2
|
||||||
path: [],
|
)`;
|
||||||
string_contains: search,
|
queryParams.push(`%${search}%`);
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
table_name: {
|
|
||||||
contains: search,
|
|
||||||
mode: "insensitive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 정렬 조건 구성
|
// 데이터 조회 쿼리
|
||||||
const orderBy: Prisma.dynamic_form_dataOrderByWithRelationInput = {};
|
const dataQuery = `
|
||||||
if (sortBy === "created_at" || sortBy === "updated_at") {
|
SELECT id, screen_id, table_name, form_data, created_at, updated_at, created_by, updated_by
|
||||||
orderBy[sortBy] = sortOrder;
|
FROM dynamic_form_data
|
||||||
} else {
|
WHERE screen_id = $1
|
||||||
orderBy.created_at = "desc"; // 기본값
|
${searchCondition}
|
||||||
}
|
ORDER BY ${validSortBy} ${validSortOrder}
|
||||||
|
LIMIT ${size} OFFSET ${offset}
|
||||||
|
`;
|
||||||
|
|
||||||
// 데이터 조회
|
// 전체 개수 조회 쿼리
|
||||||
const [results, totalCount] = await Promise.all([
|
const countQuery = `
|
||||||
prisma.dynamic_form_data.findMany({
|
SELECT COUNT(*) as total
|
||||||
where,
|
FROM dynamic_form_data
|
||||||
orderBy,
|
WHERE screen_id = $1
|
||||||
skip,
|
${searchCondition}
|
||||||
take: size,
|
`;
|
||||||
}),
|
|
||||||
prisma.dynamic_form_data.count({ where }),
|
// 병렬 실행
|
||||||
|
const [results, countResult] = await Promise.all([
|
||||||
|
query<{
|
||||||
|
id: number;
|
||||||
|
screen_id: number;
|
||||||
|
table_name: string;
|
||||||
|
form_data: any;
|
||||||
|
created_at: Date | null;
|
||||||
|
updated_at: Date | null;
|
||||||
|
created_by: string;
|
||||||
|
updated_by: string;
|
||||||
|
}>(dataQuery, queryParams),
|
||||||
|
query<{ total: string }>(countQuery, queryParams),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const totalCount = parseInt(countResult[0]?.total || "0");
|
||||||
|
|
||||||
const formDataResults: FormDataResult[] = results.map((result) => ({
|
const formDataResults: FormDataResult[] = results.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
screenId: result.screen_id,
|
screenId: result.screen_id,
|
||||||
|
|
@ -1036,22 +1057,29 @@ export class DynamicFormService {
|
||||||
console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName });
|
console.log("📊 서비스: 테이블 컬럼 정보 조회 시작:", { tableName });
|
||||||
|
|
||||||
// PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회
|
// PostgreSQL의 information_schema를 사용하여 컬럼 정보 조회
|
||||||
const columns = await prisma.$queryRaw<any[]>`
|
const columns = await query<{
|
||||||
SELECT
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
is_nullable: string;
|
||||||
|
column_default: string | null;
|
||||||
|
character_maximum_length: number | null;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
column_name,
|
column_name,
|
||||||
data_type,
|
data_type,
|
||||||
is_nullable,
|
is_nullable,
|
||||||
column_default,
|
column_default,
|
||||||
character_maximum_length
|
character_maximum_length
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = ${tableName}
|
WHERE table_name = $1
|
||||||
AND table_schema = 'public'
|
AND table_schema = 'public'
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position`,
|
||||||
`;
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
// Primary key 정보 조회
|
// Primary key 정보 조회
|
||||||
const primaryKeys = await prisma.$queryRaw<any[]>`
|
const primaryKeys = await query<{ column_name: string }>(
|
||||||
SELECT
|
`SELECT
|
||||||
kcu.column_name
|
kcu.column_name
|
||||||
FROM
|
FROM
|
||||||
information_schema.table_constraints tc
|
information_schema.table_constraints tc
|
||||||
|
|
@ -1059,9 +1087,10 @@ export class DynamicFormService {
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
WHERE
|
WHERE
|
||||||
tc.constraint_type = 'PRIMARY KEY'
|
tc.constraint_type = 'PRIMARY KEY'
|
||||||
AND tc.table_name = ${tableName}
|
AND tc.table_name = $1
|
||||||
AND tc.table_schema = 'public'
|
AND tc.table_schema = 'public'`,
|
||||||
`;
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
const primaryKeyColumns = new Set(
|
const primaryKeyColumns = new Set(
|
||||||
primaryKeys.map((pk) => pk.column_name)
|
primaryKeys.map((pk) => pk.column_name)
|
||||||
|
|
@ -1098,12 +1127,16 @@ export class DynamicFormService {
|
||||||
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
console.log(`🎯 제어관리 설정 확인 중... (screenId: ${screenId})`);
|
||||||
|
|
||||||
// 화면의 저장 버튼에서 제어관리 설정 조회
|
// 화면의 저장 버튼에서 제어관리 설정 조회
|
||||||
const screenLayouts = await prisma.screen_layouts.findMany({
|
const screenLayouts = await query<{
|
||||||
where: {
|
component_id: string;
|
||||||
screen_id: screenId,
|
properties: any;
|
||||||
component_type: "component",
|
}>(
|
||||||
},
|
`SELECT component_id, properties
|
||||||
});
|
FROM screen_layouts
|
||||||
|
WHERE screen_id = $1
|
||||||
|
AND component_type = $2`,
|
||||||
|
[screenId, "component"]
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
console.log(`📋 화면 컴포넌트 조회 결과:`, screenLayouts.length);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
class EncryptionService {
|
||||||
|
private readonly algorithm = 'aes-256-gcm';
|
||||||
|
private readonly key: Buffer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const keyString = process.env.ENCRYPTION_KEY;
|
||||||
|
if (!keyString) {
|
||||||
|
throw new Error('ENCRYPTION_KEY environment variable is required');
|
||||||
|
}
|
||||||
|
this.key = crypto.scryptSync(keyString, 'salt', 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypt(text: string): string {
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipher(this.algorithm, this.key);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt(encryptedText: string): string {
|
||||||
|
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
||||||
|
|
||||||
|
if (!ivHex || !authTagHex || !encrypted) {
|
||||||
|
throw new Error('Invalid encrypted text format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivHex, 'hex');
|
||||||
|
const authTag = Buffer.from(authTagHex, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipher(this.algorithm, this.key);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 해싱 (bcrypt 대신 사용)
|
||||||
|
hashPassword(password: string): string {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||||
|
return salt + ':' + hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPassword(password: string, hashedPassword: string): boolean {
|
||||||
|
const [salt, hash] = hashedPassword.split(':');
|
||||||
|
const verifyHash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||||
|
return hash === verifyHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 랜덤 토큰 생성
|
||||||
|
generateToken(length: number = 32): string {
|
||||||
|
return crypto.randomBytes(length).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC 서명 생성
|
||||||
|
createHmac(data: string, secret: string): string {
|
||||||
|
return crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMAC 검증
|
||||||
|
verifyHmac(data: string, signature: string, secret: string): boolean {
|
||||||
|
const expectedSignature = this.createHmac(data, secret);
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptionService = new EncryptionService();
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* 타입 안전성과 검증 강화
|
* 타입 안전성과 검증 강화
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import {
|
import {
|
||||||
WebType,
|
WebType,
|
||||||
DynamicWebType,
|
DynamicWebType,
|
||||||
|
|
@ -14,8 +14,6 @@ import {
|
||||||
} from "../types/unified-web-types";
|
} from "../types/unified-web-types";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 테이블 컬럼 정보
|
// 테이블 컬럼 정보
|
||||||
export interface TableColumn {
|
export interface TableColumn {
|
||||||
column_name: string;
|
column_name: string;
|
||||||
|
|
@ -156,17 +154,15 @@ export class EnhancedDynamicFormService {
|
||||||
*/
|
*/
|
||||||
private async validateTableExists(tableName: string): Promise<boolean> {
|
private async validateTableExists(tableName: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$queryRawUnsafe(
|
const result = await query<{ exists: boolean }>(
|
||||||
`
|
`SELECT EXISTS (
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
SELECT FROM information_schema.tables
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
) as exists
|
) as exists`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (result as any)[0]?.exists || false;
|
return result[0]?.exists || false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error);
|
console.error(`❌ 테이블 존재 여부 확인 실패: ${tableName}`, error);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -184,9 +180,8 @@ export class EnhancedDynamicFormService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const columns = (await prisma.$queryRawUnsafe(
|
const columns = await query<TableColumn>(
|
||||||
`
|
`SELECT
|
||||||
SELECT
|
|
||||||
column_name,
|
column_name,
|
||||||
data_type,
|
data_type,
|
||||||
is_nullable,
|
is_nullable,
|
||||||
|
|
@ -196,10 +191,9 @@ export class EnhancedDynamicFormService {
|
||||||
numeric_scale
|
numeric_scale
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
);
|
||||||
)) as TableColumn[];
|
|
||||||
|
|
||||||
// 캐시 저장 (10분)
|
// 캐시 저장 (10분)
|
||||||
this.columnCache.set(tableName, columns);
|
this.columnCache.set(tableName, columns);
|
||||||
|
|
@ -226,18 +220,21 @@ export class EnhancedDynamicFormService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// table_type_columns에서 웹타입 정보 조회
|
// table_type_columns에서 웹타입 정보 조회
|
||||||
const webTypeData = (await prisma.$queryRawUnsafe(
|
const webTypeData = await query<{
|
||||||
`
|
column_name: string;
|
||||||
SELECT
|
web_type: string;
|
||||||
|
is_nullable: string;
|
||||||
|
detail_settings: any;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
column_name,
|
column_name,
|
||||||
web_type,
|
web_type,
|
||||||
is_nullable,
|
is_nullable,
|
||||||
detail_settings
|
detail_settings
|
||||||
FROM table_type_columns
|
FROM table_type_columns
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({
|
const columnWebTypes: ColumnWebTypeInfo[] = webTypeData.map((row) => ({
|
||||||
columnName: row.column_name,
|
columnName: row.column_name,
|
||||||
|
|
@ -555,15 +552,13 @@ export class EnhancedDynamicFormService {
|
||||||
*/
|
*/
|
||||||
private async getPrimaryKeys(tableName: string): Promise<string[]> {
|
private async getPrimaryKeys(tableName: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const result = (await prisma.$queryRawUnsafe(
|
const result = await query<{ column_name: string }>(
|
||||||
`
|
`SELECT column_name
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.key_column_usage
|
FROM information_schema.key_column_usage
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND constraint_name LIKE '%_pkey'
|
AND constraint_name LIKE '%_pkey'`,
|
||||||
`,
|
[tableName]
|
||||||
tableName
|
);
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
return result.map((row) => row.column_name);
|
return result.map((row) => row.column_name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -594,10 +589,7 @@ export class EnhancedDynamicFormService {
|
||||||
query: insertQuery.replace(/\n\s+/g, " "),
|
query: insertQuery.replace(/\n\s+/g, " "),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await prisma.$queryRawUnsafe(
|
const result = await query<any>(insertQuery, values);
|
||||||
insertQuery,
|
|
||||||
...values
|
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: result[0],
|
data: result[0],
|
||||||
|
|
@ -649,10 +641,7 @@ export class EnhancedDynamicFormService {
|
||||||
query: updateQuery.replace(/\n\s+/g, " "),
|
query: updateQuery.replace(/\n\s+/g, " "),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = (await prisma.$queryRawUnsafe(
|
const result = await query<any>(updateQuery, updateValues);
|
||||||
updateQuery,
|
|
||||||
...updateValues
|
|
||||||
)) as any[];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: result[0],
|
data: result[0],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import prisma from "../config/database";
|
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import {
|
import {
|
||||||
EntityJoinConfig,
|
EntityJoinConfig,
|
||||||
|
|
@ -26,20 +25,20 @@ export class EntityJoinService {
|
||||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||||
|
|
||||||
// column_labels에서 entity 타입인 컬럼들 조회
|
// column_labels에서 entity 타입인 컬럼들 조회
|
||||||
const entityColumns = await prisma.column_labels.findMany({
|
const entityColumns = await query<{
|
||||||
where: {
|
column_name: string;
|
||||||
table_name: tableName,
|
reference_table: string;
|
||||||
web_type: "entity",
|
reference_column: string;
|
||||||
reference_table: { not: null },
|
display_column: string | null;
|
||||||
reference_column: { not: null },
|
}>(
|
||||||
},
|
`SELECT column_name, reference_table, reference_column, display_column
|
||||||
select: {
|
FROM column_labels
|
||||||
column_name: true,
|
WHERE table_name = $1
|
||||||
reference_table: true,
|
AND web_type = $2
|
||||||
reference_column: true,
|
AND reference_table IS NOT NULL
|
||||||
display_column: true,
|
AND reference_column IS NOT NULL`,
|
||||||
},
|
[tableName, "entity"]
|
||||||
});
|
);
|
||||||
|
|
||||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||||
entityColumns.forEach((col, index) => {
|
entityColumns.forEach((col, index) => {
|
||||||
|
|
@ -401,13 +400,14 @@ export class EntityJoinService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 참조 테이블 존재 확인
|
// 참조 테이블 존재 확인
|
||||||
const tableExists = await prisma.$queryRaw`
|
const tableExists = await query<{ exists: number }>(
|
||||||
SELECT 1 FROM information_schema.tables
|
`SELECT 1 as exists FROM information_schema.tables
|
||||||
WHERE table_name = ${config.referenceTable}
|
WHERE table_name = $1
|
||||||
LIMIT 1
|
LIMIT 1`,
|
||||||
`;
|
[config.referenceTable]
|
||||||
|
);
|
||||||
|
|
||||||
if (!Array.isArray(tableExists) || tableExists.length === 0) {
|
if (tableExists.length === 0) {
|
||||||
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
|
logger.warn(`참조 테이블이 존재하지 않음: ${config.referenceTable}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -420,14 +420,15 @@ export class EntityJoinService {
|
||||||
|
|
||||||
// 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
|
// 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
|
||||||
if (displayColumn && displayColumn !== "none") {
|
if (displayColumn && displayColumn !== "none") {
|
||||||
const columnExists = await prisma.$queryRaw`
|
const columnExists = await query<{ exists: number }>(
|
||||||
SELECT 1 FROM information_schema.columns
|
`SELECT 1 as exists FROM information_schema.columns
|
||||||
WHERE table_name = ${config.referenceTable}
|
WHERE table_name = $1
|
||||||
AND column_name = ${displayColumn}
|
AND column_name = $2
|
||||||
LIMIT 1
|
LIMIT 1`,
|
||||||
`;
|
[config.referenceTable, displayColumn]
|
||||||
|
);
|
||||||
|
|
||||||
if (!Array.isArray(columnExists) || columnExists.length === 0) {
|
if (columnExists.length === 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
|
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
|
||||||
);
|
);
|
||||||
|
|
@ -528,27 +529,30 @@ export class EntityJoinService {
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
// 1. 테이블의 기본 컬럼 정보 조회
|
// 1. 테이블의 기본 컬럼 정보 조회
|
||||||
const columns = (await prisma.$queryRaw`
|
const columns = await query<{
|
||||||
SELECT
|
column_name: string;
|
||||||
|
data_type: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
column_name,
|
column_name,
|
||||||
data_type
|
data_type
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name = ${tableName}
|
WHERE table_name = $1
|
||||||
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
AND data_type IN ('character varying', 'varchar', 'text', 'char')
|
||||||
ORDER BY ordinal_position
|
ORDER BY ordinal_position`,
|
||||||
`) as Array<{
|
[tableName]
|
||||||
column_name: string;
|
);
|
||||||
data_type: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
// 2. column_labels 테이블에서 라벨 정보 조회
|
// 2. column_labels 테이블에서 라벨 정보 조회
|
||||||
const columnLabels = await prisma.column_labels.findMany({
|
const columnLabels = await query<{
|
||||||
where: { table_name: tableName },
|
column_name: string;
|
||||||
select: {
|
column_label: string | null;
|
||||||
column_name: true,
|
}>(
|
||||||
column_label: true,
|
`SELECT column_name, column_label
|
||||||
},
|
FROM column_labels
|
||||||
});
|
WHERE table_name = $1`,
|
||||||
|
[tableName]
|
||||||
|
);
|
||||||
|
|
||||||
// 3. 라벨 정보를 맵으로 변환
|
// 3. 라벨 정보를 맵으로 변환
|
||||||
const labelMap = new Map<string, string>();
|
const labelMap = new Map<string, string>();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 조건 노드 타입 정의
|
// 조건 노드 타입 정의
|
||||||
interface ConditionNode {
|
interface ConditionNode {
|
||||||
id: string; // 고유 ID
|
id: string; // 고유 ID
|
||||||
|
|
@ -92,15 +90,16 @@ export class EventTriggerService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색
|
// 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색
|
||||||
const diagrams = (await prisma.$queryRaw`
|
const diagrams = await query<any>(
|
||||||
SELECT * FROM dataflow_diagrams
|
`SELECT * FROM dataflow_diagrams
|
||||||
WHERE company_code = ${companyCode}
|
WHERE company_code = $1
|
||||||
AND (
|
AND (
|
||||||
category::text = '"data-save"' OR
|
category::text = '"data-save"' OR
|
||||||
category::jsonb ? 'data-save' OR
|
category::jsonb ? 'data-save' OR
|
||||||
category::jsonb @> '["data-save"]'
|
category::jsonb @> '["data-save"]'
|
||||||
)
|
)`,
|
||||||
`) as any[];
|
[companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
// 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링
|
// 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링
|
||||||
const matchingDiagrams = diagrams.filter((diagram) => {
|
const matchingDiagrams = diagrams.filter((diagram) => {
|
||||||
|
|
@ -537,13 +536,14 @@ export class EventTriggerService {
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 동적 테이블 INSERT 실행
|
// 동적 테이블 INSERT 실행
|
||||||
const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(", ")}) VALUES (${Object.keys(
|
// PostgreSQL 파라미터 플레이스홀더로 변경 (? → $1, $2, ...)
|
||||||
data
|
const values = Object.values(data);
|
||||||
)
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
.map(() => "?")
|
const sql = `INSERT INTO ${tableName} (${Object.keys(data).join(
|
||||||
.join(", ")})`;
|
", "
|
||||||
|
)}) VALUES (${placeholders})`;
|
||||||
|
|
||||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
await query(sql, values);
|
||||||
logger.info(`Inserted data into ${tableName}:`, data);
|
logger.info(`Inserted data into ${tableName}:`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,14 +563,15 @@ export class EventTriggerService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 테이블 UPDATE 실행
|
// 동적 테이블 UPDATE 실행
|
||||||
|
const values = Object.values(data);
|
||||||
const setClause = Object.keys(data)
|
const setClause = Object.keys(data)
|
||||||
.map((key) => `${key} = ?`)
|
.map((key, i) => `${key} = $${i + 1}`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
const whereClause = this.buildWhereClause(conditions);
|
const whereClause = this.buildWhereClause(conditions);
|
||||||
|
|
||||||
const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`;
|
const sql = `UPDATE ${tableName} SET ${setClause} WHERE ${whereClause}`;
|
||||||
|
|
||||||
await prisma.$executeRawUnsafe(sql, ...Object.values(data));
|
await query(sql, values);
|
||||||
logger.info(`Updated data in ${tableName}:`, data);
|
logger.info(`Updated data in ${tableName}:`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,7 +594,7 @@ export class EventTriggerService {
|
||||||
const whereClause = this.buildWhereClause(conditions);
|
const whereClause = this.buildWhereClause(conditions);
|
||||||
const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`;
|
const sql = `DELETE FROM ${tableName} WHERE ${whereClause}`;
|
||||||
|
|
||||||
await prisma.$executeRawUnsafe(sql);
|
await query(sql, []);
|
||||||
logger.info(`Deleted data from ${tableName} with conditions`);
|
logger.info(`Deleted data from ${tableName} with conditions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -608,15 +609,16 @@ export class EventTriggerService {
|
||||||
const columns = Object.keys(data);
|
const columns = Object.keys(data);
|
||||||
const values = Object.values(data);
|
const values = Object.values(data);
|
||||||
const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼
|
const conflictColumns = ["id", "company_code"]; // 기본 충돌 컬럼
|
||||||
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO ${tableName} (${columns.join(", ")})
|
INSERT INTO ${tableName} (${columns.join(", ")})
|
||||||
VALUES (${columns.map(() => "?").join(", ")})
|
VALUES (${placeholders})
|
||||||
ON CONFLICT (${conflictColumns.join(", ")})
|
ON CONFLICT (${conflictColumns.join(", ")})
|
||||||
DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")}
|
DO UPDATE SET ${columns.map((col) => `${col} = EXCLUDED.${col}`).join(", ")}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await prisma.$executeRawUnsafe(sql, ...values);
|
await query(sql, values);
|
||||||
logger.info(`Upserted data into ${tableName}:`, data);
|
logger.info(`Upserted data into ${tableName}:`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -678,9 +680,10 @@ export class EventTriggerService {
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<{ conditionMet: boolean; result?: ExecutionResult }> {
|
): Promise<{ conditionMet: boolean; result?: ExecutionResult }> {
|
||||||
try {
|
try {
|
||||||
const diagram = await prisma.dataflow_diagrams.findUnique({
|
const diagram = await queryOne<any>(
|
||||||
where: { diagram_id: diagramId },
|
`SELECT * FROM dataflow_diagrams WHERE diagram_id = $1`,
|
||||||
});
|
[diagramId]
|
||||||
|
);
|
||||||
|
|
||||||
if (!diagram) {
|
if (!diagram) {
|
||||||
throw new Error(`Diagram ${diagramId} not found`);
|
throw new Error(`Diagram ${diagramId} not found`);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import prisma from "../config/database";
|
import { query, queryOne } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
// 외부 호출 설정 타입 정의
|
// 외부 호출 설정 타입 정의
|
||||||
|
|
@ -34,43 +34,55 @@ export class ExternalCallConfigService {
|
||||||
logger.info("=== 외부 호출 설정 목록 조회 시작 ===");
|
logger.info("=== 외부 호출 설정 목록 조회 시작 ===");
|
||||||
logger.info(`필터 조건:`, filter);
|
logger.info(`필터 조건:`, filter);
|
||||||
|
|
||||||
const where: any = {};
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 회사 코드 필터
|
// 회사 코드 필터
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
where.company_code = filter.company_code;
|
conditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 호출 타입 필터
|
// 호출 타입 필터
|
||||||
if (filter.call_type) {
|
if (filter.call_type) {
|
||||||
where.call_type = filter.call_type;
|
conditions.push(`call_type = $${paramIndex++}`);
|
||||||
|
params.push(filter.call_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 타입 필터
|
// API 타입 필터
|
||||||
if (filter.api_type) {
|
if (filter.api_type) {
|
||||||
where.api_type = filter.api_type;
|
conditions.push(`api_type = $${paramIndex++}`);
|
||||||
|
params.push(filter.api_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 활성화 상태 필터
|
// 활성화 상태 필터
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
where.is_active = filter.is_active;
|
conditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(filter.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색어 필터 (설정 이름 또는 설명)
|
// 검색어 필터 (설정 이름 또는 설명)
|
||||||
if (filter.search) {
|
if (filter.search) {
|
||||||
where.OR = [
|
conditions.push(
|
||||||
{ config_name: { contains: filter.search, mode: "insensitive" } },
|
`(config_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
{ description: { contains: filter.search, mode: "insensitive" } },
|
);
|
||||||
];
|
params.push(`%${filter.search}%`);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const configs = await prisma.external_call_configs.findMany({
|
const whereClause =
|
||||||
where,
|
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||||
orderBy: [{ is_active: "desc" }, { created_date: "desc" }],
|
|
||||||
});
|
const configs = await query<ExternalCallConfig>(
|
||||||
|
`SELECT * FROM external_call_configs
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY is_active DESC, created_date DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(`외부 호출 설정 조회 결과: ${configs.length}개`);
|
logger.info(`외부 호출 설정 조회 결과: ${configs.length}개`);
|
||||||
return configs as ExternalCallConfig[];
|
return configs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("외부 호출 설정 목록 조회 실패:", error);
|
logger.error("외부 호출 설정 목록 조회 실패:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -84,9 +96,10 @@ export class ExternalCallConfigService {
|
||||||
try {
|
try {
|
||||||
logger.info(`=== 외부 호출 설정 조회: ID ${id} ===`);
|
logger.info(`=== 외부 호출 설정 조회: ID ${id} ===`);
|
||||||
|
|
||||||
const config = await prisma.external_call_configs.findUnique({
|
const config = await queryOne<ExternalCallConfig>(
|
||||||
where: { id },
|
`SELECT * FROM external_call_configs WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (config) {
|
if (config) {
|
||||||
logger.info(`외부 호출 설정 조회 성공: ${config.config_name}`);
|
logger.info(`외부 호출 설정 조회 성공: ${config.config_name}`);
|
||||||
|
|
@ -94,7 +107,7 @@ export class ExternalCallConfigService {
|
||||||
logger.warn(`외부 호출 설정을 찾을 수 없음: ID ${id}`);
|
logger.warn(`외부 호출 설정을 찾을 수 없음: ID ${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return config as ExternalCallConfig | null;
|
return config || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
|
logger.error(`외부 호출 설정 조회 실패 (ID: ${id}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -115,13 +128,11 @@ export class ExternalCallConfigService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// 중복 이름 검사
|
// 중복 이름 검사
|
||||||
const existingConfig = await prisma.external_call_configs.findFirst({
|
const existingConfig = await queryOne<ExternalCallConfig>(
|
||||||
where: {
|
`SELECT * FROM external_call_configs
|
||||||
config_name: data.config_name,
|
WHERE config_name = $1 AND company_code = $2 AND is_active = $3`,
|
||||||
company_code: data.company_code || "*",
|
[data.config_name, data.company_code || "*", "Y"]
|
||||||
is_active: "Y",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -129,24 +140,29 @@ export class ExternalCallConfigService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConfig = await prisma.external_call_configs.create({
|
const newConfig = await queryOne<ExternalCallConfig>(
|
||||||
data: {
|
`INSERT INTO external_call_configs
|
||||||
config_name: data.config_name,
|
(config_name, call_type, api_type, config_data, description,
|
||||||
call_type: data.call_type,
|
company_code, is_active, created_by, updated_by, created_date, updated_date)
|
||||||
api_type: data.api_type,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||||
config_data: data.config_data,
|
RETURNING *`,
|
||||||
description: data.description,
|
[
|
||||||
company_code: data.company_code || "*",
|
data.config_name,
|
||||||
is_active: data.is_active || "Y",
|
data.call_type,
|
||||||
created_by: data.created_by,
|
data.api_type,
|
||||||
updated_by: data.updated_by,
|
JSON.stringify(data.config_data),
|
||||||
},
|
data.description,
|
||||||
});
|
data.company_code || "*",
|
||||||
|
data.is_active || "Y",
|
||||||
|
data.created_by,
|
||||||
|
data.updated_by,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`외부 호출 설정 생성 완료: ${newConfig.config_name} (ID: ${newConfig.id})`
|
`외부 호출 설정 생성 완료: ${newConfig!.config_name} (ID: ${newConfig!.id})`
|
||||||
);
|
);
|
||||||
return newConfig as ExternalCallConfig;
|
return newConfig!;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("외부 호출 설정 생성 실패:", error);
|
logger.error("외부 호출 설정 생성 실패:", error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -171,14 +187,16 @@ export class ExternalCallConfigService {
|
||||||
|
|
||||||
// 이름 중복 검사 (다른 설정과 중복되는지)
|
// 이름 중복 검사 (다른 설정과 중복되는지)
|
||||||
if (data.config_name && data.config_name !== existingConfig.config_name) {
|
if (data.config_name && data.config_name !== existingConfig.config_name) {
|
||||||
const duplicateConfig = await prisma.external_call_configs.findFirst({
|
const duplicateConfig = await queryOne<ExternalCallConfig>(
|
||||||
where: {
|
`SELECT * FROM external_call_configs
|
||||||
config_name: data.config_name,
|
WHERE config_name = $1 AND company_code = $2 AND is_active = $3 AND id != $4`,
|
||||||
company_code: data.company_code || existingConfig.company_code,
|
[
|
||||||
is_active: "Y",
|
data.config_name,
|
||||||
id: { not: id },
|
data.company_code || existingConfig.company_code,
|
||||||
},
|
"Y",
|
||||||
});
|
id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (duplicateConfig) {
|
if (duplicateConfig) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -187,27 +205,58 @@ export class ExternalCallConfigService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedConfig = await prisma.external_call_configs.update({
|
// 동적 UPDATE 쿼리 생성
|
||||||
where: { id },
|
const updateFields: string[] = ["updated_date = NOW()"];
|
||||||
data: {
|
const params: any[] = [];
|
||||||
...(data.config_name && { config_name: data.config_name }),
|
let paramIndex = 1;
|
||||||
...(data.call_type && { call_type: data.call_type }),
|
|
||||||
...(data.api_type !== undefined && { api_type: data.api_type }),
|
if (data.config_name) {
|
||||||
...(data.config_data && { config_data: data.config_data }),
|
updateFields.push(`config_name = $${paramIndex++}`);
|
||||||
...(data.description !== undefined && {
|
params.push(data.config_name);
|
||||||
description: data.description,
|
}
|
||||||
}),
|
if (data.call_type) {
|
||||||
...(data.company_code && { company_code: data.company_code }),
|
updateFields.push(`call_type = $${paramIndex++}`);
|
||||||
...(data.is_active && { is_active: data.is_active }),
|
params.push(data.call_type);
|
||||||
...(data.updated_by && { updated_by: data.updated_by }),
|
}
|
||||||
updated_date: new Date(),
|
if (data.api_type !== undefined) {
|
||||||
},
|
updateFields.push(`api_type = $${paramIndex++}`);
|
||||||
});
|
params.push(data.api_type);
|
||||||
|
}
|
||||||
|
if (data.config_data) {
|
||||||
|
updateFields.push(`config_data = $${paramIndex++}`);
|
||||||
|
params.push(JSON.stringify(data.config_data));
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
params.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.company_code) {
|
||||||
|
updateFields.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(data.company_code);
|
||||||
|
}
|
||||||
|
if (data.is_active) {
|
||||||
|
updateFields.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(data.is_active);
|
||||||
|
}
|
||||||
|
if (data.updated_by) {
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
params.push(data.updated_by);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
|
||||||
|
const updatedConfig = await queryOne<ExternalCallConfig>(
|
||||||
|
`UPDATE external_call_configs
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`외부 호출 설정 수정 완료: ${updatedConfig.config_name} (ID: ${id})`
|
`외부 호출 설정 수정 완료: ${updatedConfig!.config_name} (ID: ${id})`
|
||||||
);
|
);
|
||||||
return updatedConfig as ExternalCallConfig;
|
return updatedConfig!;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
|
logger.error(`외부 호출 설정 수정 실패 (ID: ${id}):`, error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -228,14 +277,12 @@ export class ExternalCallConfigService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 논리 삭제 (is_active = 'N')
|
// 논리 삭제 (is_active = 'N')
|
||||||
await prisma.external_call_configs.update({
|
await query(
|
||||||
where: { id },
|
`UPDATE external_call_configs
|
||||||
data: {
|
SET is_active = $1, updated_by = $2, updated_date = NOW()
|
||||||
is_active: "N",
|
WHERE id = $3`,
|
||||||
updated_by: deletedBy,
|
["N", deletedBy, id]
|
||||||
updated_date: new Date(),
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`외부 호출 설정 삭제 완료: ${existingConfig.config_name} (ID: ${id})`
|
`외부 호출 설정 삭제 완료: ${existingConfig.config_name} (ID: ${id})`
|
||||||
|
|
@ -344,13 +391,14 @@ export class ExternalCallConfigService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 외부 API 호출
|
// 3. 외부 API 호출
|
||||||
const callResult = await this.executeExternalCall(config, processedData, contextData);
|
const callResult = await this.executeExternalCall(
|
||||||
|
config,
|
||||||
|
processedData,
|
||||||
|
contextData
|
||||||
|
);
|
||||||
|
|
||||||
// 4. Inbound 데이터 매핑 처리 (있는 경우)
|
// 4. Inbound 데이터 매핑 처리 (있는 경우)
|
||||||
if (
|
if (callResult.success && configData?.dataMappingConfig?.inboundMapping) {
|
||||||
callResult.success &&
|
|
||||||
configData?.dataMappingConfig?.inboundMapping
|
|
||||||
) {
|
|
||||||
logger.info("Inbound 데이터 매핑 처리 중...");
|
logger.info("Inbound 데이터 매핑 처리 중...");
|
||||||
await this.processInboundMapping(
|
await this.processInboundMapping(
|
||||||
configData.dataMappingConfig.inboundMapping,
|
configData.dataMappingConfig.inboundMapping,
|
||||||
|
|
@ -374,7 +422,8 @@ export class ExternalCallConfigService {
|
||||||
const executionTime = performance.now() - startTime;
|
const executionTime = performance.now() - startTime;
|
||||||
logger.error("외부호출 실행 실패:", error);
|
logger.error("외부호출 실행 실패:", error);
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
@ -388,30 +437,29 @@ export class ExternalCallConfigService {
|
||||||
/**
|
/**
|
||||||
* 🔥 버튼 제어용 외부호출 설정 목록 조회 (간소화된 정보)
|
* 🔥 버튼 제어용 외부호출 설정 목록 조회 (간소화된 정보)
|
||||||
*/
|
*/
|
||||||
async getConfigsForButtonControl(companyCode: string): Promise<Array<{
|
async getConfigsForButtonControl(companyCode: string): Promise<
|
||||||
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
method: string;
|
method: string;
|
||||||
hasDataMapping: boolean;
|
hasDataMapping: boolean;
|
||||||
}>> {
|
}>
|
||||||
|
> {
|
||||||
try {
|
try {
|
||||||
const configs = await prisma.external_call_configs.findMany({
|
const configs = await query<{
|
||||||
where: {
|
id: number;
|
||||||
company_code: companyCode,
|
config_name: string;
|
||||||
is_active: "Y",
|
description: string | null;
|
||||||
},
|
config_data: any;
|
||||||
select: {
|
}>(
|
||||||
id: true,
|
`SELECT id, config_name, description, config_data
|
||||||
config_name: true,
|
FROM external_call_configs
|
||||||
description: true,
|
WHERE company_code = $1 AND is_active = $2
|
||||||
config_data: true,
|
ORDER BY config_name ASC`,
|
||||||
},
|
[companyCode, "Y"]
|
||||||
orderBy: {
|
);
|
||||||
config_name: "asc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return configs.map((config) => {
|
return configs.map((config) => {
|
||||||
const configData = config.config_data as any;
|
const configData = config.config_data as any;
|
||||||
|
|
@ -421,7 +469,7 @@ export class ExternalCallConfigService {
|
||||||
description: config.description || undefined,
|
description: config.description || undefined,
|
||||||
apiUrl: configData?.restApiSettings?.apiUrl || "",
|
apiUrl: configData?.restApiSettings?.apiUrl || "",
|
||||||
method: configData?.restApiSettings?.httpMethod || "GET",
|
method: configData?.restApiSettings?.httpMethod || "GET",
|
||||||
hasDataMapping: !!(configData?.dataMappingConfig),
|
hasDataMapping: !!configData?.dataMappingConfig,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -445,7 +493,12 @@ export class ExternalCallConfigService {
|
||||||
throw new Error("REST API 설정이 없습니다.");
|
throw new Error("REST API 설정이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { apiUrl, httpMethod, headers = {}, timeout = 30000 } = restApiSettings;
|
const {
|
||||||
|
apiUrl,
|
||||||
|
httpMethod,
|
||||||
|
headers = {},
|
||||||
|
timeout = 30000,
|
||||||
|
} = restApiSettings;
|
||||||
|
|
||||||
// 요청 헤더 준비
|
// 요청 헤더 준비
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
|
|
@ -456,7 +509,9 @@ export class ExternalCallConfigService {
|
||||||
// 인증 처리
|
// 인증 처리
|
||||||
if (restApiSettings.authentication?.type === "basic") {
|
if (restApiSettings.authentication?.type === "basic") {
|
||||||
const { username, password } = restApiSettings.authentication;
|
const { username, password } = restApiSettings.authentication;
|
||||||
const credentials = Buffer.from(`${username}:${password}`).toString("base64");
|
const credentials = Buffer.from(`${username}:${password}`).toString(
|
||||||
|
"base64"
|
||||||
|
);
|
||||||
requestHeaders["Authorization"] = `Basic ${credentials}`;
|
requestHeaders["Authorization"] = `Basic ${credentials}`;
|
||||||
} else if (restApiSettings.authentication?.type === "bearer") {
|
} else if (restApiSettings.authentication?.type === "bearer") {
|
||||||
const { token } = restApiSettings.authentication;
|
const { token } = restApiSettings.authentication;
|
||||||
|
|
@ -495,7 +550,8 @@ export class ExternalCallConfigService {
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("외부 API 호출 실패:", error);
|
logger.error("외부 API 호출 실패:", error);
|
||||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "알 수 없는 오류";
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
|
|
@ -559,7 +615,6 @@ export class ExternalCallConfigService {
|
||||||
|
|
||||||
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
|
// 실제 구현에서는 응답 데이터를 파싱하여 내부 테이블에 저장하는 로직 필요
|
||||||
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
|
// 예: 외부 API에서 받은 사용자 정보를 내부 사용자 테이블에 업데이트
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Inbound 데이터 매핑 처리 실패:", error);
|
logger.error("Inbound 데이터 매핑 처리 실패:", error);
|
||||||
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음
|
// Inbound 매핑 실패는 전체 플로우를 중단하지 않음
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 외부 DB 연결 서비스
|
// 외부 DB 연결 서비스
|
||||||
// 작성일: 2024-12-17
|
// 작성일: 2024-12-17
|
||||||
|
|
||||||
import prisma from "../config/database";
|
import { query, queryOne } from "../database/db";
|
||||||
import {
|
import {
|
||||||
ExternalDbConnection,
|
ExternalDbConnection,
|
||||||
ExternalDbConnectionFilter,
|
ExternalDbConnectionFilter,
|
||||||
|
|
@ -20,43 +20,47 @@ export class ExternalDbConnectionService {
|
||||||
filter: ExternalDbConnectionFilter
|
filter: ExternalDbConnectionFilter
|
||||||
): Promise<ApiResponse<ExternalDbConnection[]>> {
|
): Promise<ApiResponse<ExternalDbConnection[]>> {
|
||||||
try {
|
try {
|
||||||
const where: any = {};
|
// WHERE 조건 동적 생성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
// 필터 조건 적용
|
// 필터 조건 적용
|
||||||
if (filter.db_type) {
|
if (filter.db_type) {
|
||||||
where.db_type = filter.db_type;
|
whereConditions.push(`db_type = $${paramIndex++}`);
|
||||||
|
params.push(filter.db_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.is_active) {
|
if (filter.is_active) {
|
||||||
where.is_active = filter.is_active;
|
whereConditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(filter.is_active);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.company_code) {
|
if (filter.company_code) {
|
||||||
where.company_code = filter.company_code;
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
params.push(filter.company_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
// 검색 조건 적용 (연결명 또는 설명에서 검색)
|
||||||
if (filter.search && filter.search.trim()) {
|
if (filter.search && filter.search.trim()) {
|
||||||
where.OR = [
|
whereConditions.push(
|
||||||
{
|
`(connection_name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
connection_name: {
|
);
|
||||||
contains: filter.search.trim(),
|
params.push(`%${filter.search.trim()}%`);
|
||||||
mode: "insensitive",
|
paramIndex++;
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: {
|
|
||||||
contains: filter.search.trim(),
|
|
||||||
mode: "insensitive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = await prisma.external_db_connections.findMany({
|
const whereClause =
|
||||||
where,
|
whereConditions.length > 0
|
||||||
orderBy: [{ is_active: "desc" }, { connection_name: "asc" }],
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
});
|
: "";
|
||||||
|
|
||||||
|
const connections = await query<any>(
|
||||||
|
`SELECT * FROM external_db_connections
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY is_active DESC, connection_name ASC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
// 비밀번호는 반환하지 않음 (보안)
|
// 비밀번호는 반환하지 않음 (보안)
|
||||||
const safeConnections = connections.map((conn) => ({
|
const safeConnections = connections.map((conn) => ({
|
||||||
|
|
@ -93,18 +97,17 @@ export class ExternalDbConnectionService {
|
||||||
if (!connectionsResult.success || !connectionsResult.data) {
|
if (!connectionsResult.success || !connectionsResult.data) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "연결 목록 조회에 실패했습니다."
|
message: "연결 목록 조회에 실패했습니다.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 타입 카테고리 정보 조회
|
// DB 타입 카테고리 정보 조회
|
||||||
const categories = await prisma.db_type_categories.findMany({
|
const categories = await query<any>(
|
||||||
where: { is_active: true },
|
`SELECT * FROM db_type_categories
|
||||||
orderBy: [
|
WHERE is_active = true
|
||||||
{ sort_order: 'asc' },
|
ORDER BY sort_order ASC, display_name ASC`,
|
||||||
{ display_name: 'asc' }
|
[]
|
||||||
]
|
);
|
||||||
});
|
|
||||||
|
|
||||||
// DB 타입별로 그룹화
|
// DB 타입별로 그룹화
|
||||||
const groupedConnections: Record<string, any> = {};
|
const groupedConnections: Record<string, any> = {};
|
||||||
|
|
@ -117,36 +120,36 @@ export class ExternalDbConnectionService {
|
||||||
display_name: category.display_name,
|
display_name: category.display_name,
|
||||||
icon: category.icon,
|
icon: category.icon,
|
||||||
color: category.color,
|
color: category.color,
|
||||||
sort_order: category.sort_order
|
sort_order: category.sort_order,
|
||||||
},
|
},
|
||||||
connections: []
|
connections: [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 연결을 해당 타입 그룹에 배치
|
// 연결을 해당 타입 그룹에 배치
|
||||||
connectionsResult.data.forEach(connection => {
|
connectionsResult.data.forEach((connection) => {
|
||||||
if (groupedConnections[connection.db_type]) {
|
if (groupedConnections[connection.db_type]) {
|
||||||
groupedConnections[connection.db_type].connections.push(connection);
|
groupedConnections[connection.db_type].connections.push(connection);
|
||||||
} else {
|
} else {
|
||||||
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
|
||||||
if (!groupedConnections['other']) {
|
if (!groupedConnections["other"]) {
|
||||||
groupedConnections['other'] = {
|
groupedConnections["other"] = {
|
||||||
category: {
|
category: {
|
||||||
type_code: 'other',
|
type_code: "other",
|
||||||
display_name: '기타',
|
display_name: "기타",
|
||||||
icon: 'database',
|
icon: "database",
|
||||||
color: '#6B7280',
|
color: "#6B7280",
|
||||||
sort_order: 999
|
sort_order: 999,
|
||||||
},
|
},
|
||||||
connections: []
|
connections: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
groupedConnections['other'].connections.push(connection);
|
groupedConnections["other"].connections.push(connection);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 연결이 없는 빈 그룹 제거
|
// 연결이 없는 빈 그룹 제거
|
||||||
Object.keys(groupedConnections).forEach(key => {
|
Object.keys(groupedConnections).forEach((key) => {
|
||||||
if (groupedConnections[key].connections.length === 0) {
|
if (groupedConnections[key].connections.length === 0) {
|
||||||
delete groupedConnections[key];
|
delete groupedConnections[key];
|
||||||
}
|
}
|
||||||
|
|
@ -155,14 +158,14 @@ export class ExternalDbConnectionService {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: groupedConnections,
|
data: groupedConnections,
|
||||||
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`
|
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("그룹화된 연결 목록 조회 실패:", error);
|
console.error("그룹화된 연결 목록 조회 실패:", error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "알 수 없는 오류"
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,9 +177,10 @@ export class ExternalDbConnectionService {
|
||||||
id: number
|
id: number
|
||||||
): Promise<ApiResponse<ExternalDbConnection>> {
|
): Promise<ApiResponse<ExternalDbConnection>> {
|
||||||
try {
|
try {
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -214,9 +218,10 @@ export class ExternalDbConnectionService {
|
||||||
id: number
|
id: number
|
||||||
): Promise<ApiResponse<ExternalDbConnection>> {
|
): Promise<ApiResponse<ExternalDbConnection>> {
|
||||||
try {
|
try {
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -257,13 +262,11 @@ export class ExternalDbConnectionService {
|
||||||
this.validateConnectionData(data);
|
this.validateConnectionData(data);
|
||||||
|
|
||||||
// 연결명 중복 확인
|
// 연결명 중복 확인
|
||||||
const existingConnection = await prisma.external_db_connections.findFirst(
|
const existingConnection = await queryOne(
|
||||||
{
|
`SELECT id FROM external_db_connections
|
||||||
where: {
|
WHERE connection_name = $1 AND company_code = $2
|
||||||
connection_name: data.connection_name,
|
LIMIT 1`,
|
||||||
company_code: data.company_code,
|
[data.connection_name, data.company_code]
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingConnection) {
|
if (existingConnection) {
|
||||||
|
|
@ -276,30 +279,35 @@ export class ExternalDbConnectionService {
|
||||||
// 비밀번호 암호화
|
// 비밀번호 암호화
|
||||||
const encryptedPassword = PasswordEncryption.encrypt(data.password);
|
const encryptedPassword = PasswordEncryption.encrypt(data.password);
|
||||||
|
|
||||||
const newConnection = await prisma.external_db_connections.create({
|
const newConnection = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO external_db_connections (
|
||||||
connection_name: data.connection_name,
|
connection_name, description, db_type, host, port, database_name,
|
||||||
description: data.description,
|
username, password, connection_timeout, query_timeout, max_connections,
|
||||||
db_type: data.db_type,
|
ssl_enabled, ssl_cert_path, connection_options, company_code, is_active,
|
||||||
host: data.host,
|
created_by, updated_by, created_date, updated_date
|
||||||
port: data.port,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, NOW(), NOW())
|
||||||
database_name: data.database_name,
|
RETURNING *`,
|
||||||
username: data.username,
|
[
|
||||||
password: encryptedPassword,
|
data.connection_name,
|
||||||
connection_timeout: data.connection_timeout,
|
data.description,
|
||||||
query_timeout: data.query_timeout,
|
data.db_type,
|
||||||
max_connections: data.max_connections,
|
data.host,
|
||||||
ssl_enabled: data.ssl_enabled,
|
data.port,
|
||||||
ssl_cert_path: data.ssl_cert_path,
|
data.database_name,
|
||||||
connection_options: data.connection_options as any,
|
data.username,
|
||||||
company_code: data.company_code,
|
encryptedPassword,
|
||||||
is_active: data.is_active,
|
data.connection_timeout,
|
||||||
created_by: data.created_by,
|
data.query_timeout,
|
||||||
updated_by: data.updated_by,
|
data.max_connections,
|
||||||
created_date: new Date(),
|
data.ssl_enabled,
|
||||||
updated_date: new Date(),
|
data.ssl_cert_path,
|
||||||
},
|
JSON.stringify(data.connection_options),
|
||||||
});
|
data.company_code,
|
||||||
|
data.is_active,
|
||||||
|
data.created_by,
|
||||||
|
data.updated_by,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// 비밀번호는 반환하지 않음
|
// 비밀번호는 반환하지 않음
|
||||||
const safeConnection = {
|
const safeConnection = {
|
||||||
|
|
@ -332,10 +340,10 @@ export class ExternalDbConnectionService {
|
||||||
): Promise<ApiResponse<ExternalDbConnection>> {
|
): Promise<ApiResponse<ExternalDbConnection>> {
|
||||||
try {
|
try {
|
||||||
// 기존 연결 확인
|
// 기존 연결 확인
|
||||||
const existingConnection =
|
const existingConnection = await queryOne<any>(
|
||||||
await prisma.external_db_connections.findUnique({
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
where: { id },
|
[id]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!existingConnection) {
|
if (!existingConnection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -346,15 +354,18 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
// 연결명 중복 확인 (자신 제외)
|
// 연결명 중복 확인 (자신 제외)
|
||||||
if (data.connection_name) {
|
if (data.connection_name) {
|
||||||
const duplicateConnection =
|
const duplicateConnection = await queryOne(
|
||||||
await prisma.external_db_connections.findFirst({
|
`SELECT id FROM external_db_connections
|
||||||
where: {
|
WHERE connection_name = $1
|
||||||
connection_name: data.connection_name,
|
AND company_code = $2
|
||||||
company_code:
|
AND id != $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[
|
||||||
|
data.connection_name,
|
||||||
data.company_code || existingConnection.company_code,
|
data.company_code || existingConnection.company_code,
|
||||||
id: { not: id },
|
id,
|
||||||
},
|
]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (duplicateConnection) {
|
if (duplicateConnection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -406,23 +417,59 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 업데이트 데이터 준비
|
// 업데이트 데이터 준비
|
||||||
const updateData: any = {
|
const updates: string[] = [];
|
||||||
...data,
|
const updateParams: any[] = [];
|
||||||
updated_date: new Date(),
|
let paramIndex = 1;
|
||||||
};
|
|
||||||
|
// 각 필드를 동적으로 추가
|
||||||
|
const fields = [
|
||||||
|
"connection_name",
|
||||||
|
"description",
|
||||||
|
"db_type",
|
||||||
|
"host",
|
||||||
|
"port",
|
||||||
|
"database_name",
|
||||||
|
"username",
|
||||||
|
"connection_timeout",
|
||||||
|
"query_timeout",
|
||||||
|
"max_connections",
|
||||||
|
"ssl_enabled",
|
||||||
|
"ssl_cert_path",
|
||||||
|
"connection_options",
|
||||||
|
"company_code",
|
||||||
|
"is_active",
|
||||||
|
"updated_by",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (data[field as keyof ExternalDbConnection] !== undefined) {
|
||||||
|
updates.push(`${field} = $${paramIndex++}`);
|
||||||
|
const value = data[field as keyof ExternalDbConnection];
|
||||||
|
updateParams.push(
|
||||||
|
field === "connection_options" ? JSON.stringify(value) : value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
// 비밀번호가 변경된 경우 암호화 (연결 테스트 통과 후)
|
||||||
if (data.password && data.password !== "***ENCRYPTED***") {
|
if (data.password && data.password !== "***ENCRYPTED***") {
|
||||||
updateData.password = PasswordEncryption.encrypt(data.password);
|
updates.push(`password = $${paramIndex++}`);
|
||||||
} else {
|
updateParams.push(PasswordEncryption.encrypt(data.password));
|
||||||
// 비밀번호 필드 제거 (변경하지 않음)
|
|
||||||
delete updateData.password;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedConnection = await prisma.external_db_connections.update({
|
// updated_date는 항상 업데이트
|
||||||
where: { id },
|
updates.push(`updated_date = NOW()`);
|
||||||
data: updateData,
|
|
||||||
});
|
// id 파라미터 추가
|
||||||
|
updateParams.push(id);
|
||||||
|
|
||||||
|
const updatedConnection = await queryOne<any>(
|
||||||
|
`UPDATE external_db_connections
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE id = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
updateParams
|
||||||
|
);
|
||||||
|
|
||||||
// 비밀번호는 반환하지 않음
|
// 비밀번호는 반환하지 않음
|
||||||
const safeConnection = {
|
const safeConnection = {
|
||||||
|
|
@ -451,10 +498,10 @@ export class ExternalDbConnectionService {
|
||||||
*/
|
*/
|
||||||
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
|
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
const existingConnection =
|
const existingConnection = await queryOne(
|
||||||
await prisma.external_db_connections.findUnique({
|
`SELECT id FROM external_db_connections WHERE id = $1`,
|
||||||
where: { id },
|
[id]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!existingConnection) {
|
if (!existingConnection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -464,9 +511,7 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 물리 삭제 (실제 데이터 삭제)
|
// 물리 삭제 (실제 데이터 삭제)
|
||||||
await prisma.external_db_connections.delete({
|
await query(`DELETE FROM external_db_connections WHERE id = $1`, [id]);
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -491,9 +536,10 @@ export class ExternalDbConnectionService {
|
||||||
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
try {
|
try {
|
||||||
// 저장된 연결 정보 조회
|
// 저장된 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -674,10 +720,10 @@ export class ExternalDbConnectionService {
|
||||||
*/
|
*/
|
||||||
static async getDecryptedPassword(id: number): Promise<string | null> {
|
static async getDecryptedPassword(id: number): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<{ password: string }>(
|
||||||
where: { id },
|
`SELECT password FROM external_db_connections WHERE id = $1`,
|
||||||
select: { password: true },
|
[id]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -699,11 +745,36 @@ export class ExternalDbConnectionService {
|
||||||
params: any[] = []
|
params: any[] = []
|
||||||
): Promise<ApiResponse<any[]>> {
|
): Promise<ApiResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
|
// 보안 검증: SELECT 쿼리만 허용
|
||||||
|
const trimmedQuery = query.trim().toUpperCase();
|
||||||
|
if (!trimmedQuery.startsWith('SELECT')) {
|
||||||
|
console.log("보안 오류: SELECT가 아닌 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "외부 데이터베이스에서는 SELECT 쿼리만 실행할 수 있습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위험한 키워드 검사
|
||||||
|
const dangerousKeywords = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TRUNCATE', 'EXEC', 'EXECUTE', 'CALL', 'MERGE'];
|
||||||
|
const hasDangerousKeyword = dangerousKeywords.some(keyword =>
|
||||||
|
trimmedQuery.includes(keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDangerousKeyword) {
|
||||||
|
console.log("보안 오류: 위험한 키워드 포함 쿼리 시도:", { id, query: query.substring(0, 100) });
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "데이터를 변경하거나 삭제하는 쿼리는 허용되지 않습니다. SELECT 쿼리만 사용해주세요.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
console.log("연결 정보 조회 시작:", { id });
|
console.log("연결 정보 조회 시작:", { id });
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
console.log("조회된 연결 정보:", connection);
|
console.log("조회된 연결 정보:", connection);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
|
|
@ -753,14 +824,25 @@ export class ExternalDbConnectionService {
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
const dbType = connection.db_type?.toLowerCase() || 'postgresql';
|
const dbType = connection.db_type?.toLowerCase() || "postgresql";
|
||||||
|
|
||||||
// 파라미터 바인딩을 지원하는 DB 타입들
|
// 파라미터 바인딩을 지원하는 DB 타입들
|
||||||
const supportedDbTypes = ['oracle', 'mysql', 'mariadb', 'postgresql', 'sqlite', 'sqlserver', 'mssql'];
|
const supportedDbTypes = [
|
||||||
|
"oracle",
|
||||||
|
"mysql",
|
||||||
|
"mariadb",
|
||||||
|
"postgresql",
|
||||||
|
"sqlite",
|
||||||
|
"sqlserver",
|
||||||
|
"mssql",
|
||||||
|
];
|
||||||
|
|
||||||
if (supportedDbTypes.includes(dbType) && params.length > 0) {
|
if (supportedDbTypes.includes(dbType) && params.length > 0) {
|
||||||
// 파라미터 바인딩 지원 DB: 안전한 파라미터 바인딩 사용
|
// 파라미터 바인딩 지원 DB: 안전한 파라미터 바인딩 사용
|
||||||
logger.info(`${dbType.toUpperCase()} 파라미터 바인딩 실행:`, { query, params });
|
logger.info(`${dbType.toUpperCase()} 파라미터 바인딩 실행:`, {
|
||||||
|
query,
|
||||||
|
params,
|
||||||
|
});
|
||||||
result = await (connector as any).executeQuery(query, params);
|
result = await (connector as any).executeQuery(query, params);
|
||||||
} else {
|
} else {
|
||||||
// 파라미터가 없거나 지원하지 않는 DB: 기본 방식 사용
|
// 파라미터가 없거나 지원하지 않는 DB: 기본 방식 사용
|
||||||
|
|
@ -846,9 +928,10 @@ export class ExternalDbConnectionService {
|
||||||
static async getTables(id: number): Promise<ApiResponse<TableInfo[]>> {
|
static async getTables(id: number): Promise<ApiResponse<TableInfo[]>> {
|
||||||
try {
|
try {
|
||||||
// 연결 정보 조회
|
// 연결 정보 조회
|
||||||
const connection = await prisma.external_db_connections.findUnique({
|
const connection = await queryOne<any>(
|
||||||
where: { id },
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
});
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -1122,4 +1205,157 @@ export class ExternalDbConnectionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 외부 DB 연결의 테이블 목록 조회
|
||||||
|
*/
|
||||||
|
static async getTablesFromConnection(
|
||||||
|
connectionId: number
|
||||||
|
): Promise<ApiResponse<TableInfo[]>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await queryOne<any>(
|
||||||
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const password = connection.password
|
||||||
|
? PasswordEncryption.decrypt(connection.password)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: password,
|
||||||
|
connectionTimeoutMillis:
|
||||||
|
connection.connection_timeout != null
|
||||||
|
? connection.connection_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
queryTimeoutMillis:
|
||||||
|
connection.query_timeout != null
|
||||||
|
? connection.query_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
ssl:
|
||||||
|
connection.ssl_enabled === "Y"
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type,
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = await connector.getTables();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: tables,
|
||||||
|
message: `${tables.length}개의 테이블을 조회했습니다.`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await DatabaseConnectorFactory.closeConnector(
|
||||||
|
connectionId,
|
||||||
|
connection.db_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("테이블 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||||
|
*/
|
||||||
|
static async getColumnsFromConnection(
|
||||||
|
connectionId: number,
|
||||||
|
tableName: string
|
||||||
|
): Promise<ApiResponse<any[]>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connection = await queryOne<any>(
|
||||||
|
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||||
|
[connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const password = connection.password
|
||||||
|
? PasswordEncryption.decrypt(connection.password)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// 연결 설정 준비
|
||||||
|
const config = {
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
database: connection.database_name,
|
||||||
|
user: connection.username,
|
||||||
|
password: password,
|
||||||
|
connectionTimeoutMillis:
|
||||||
|
connection.connection_timeout != null
|
||||||
|
? connection.connection_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
queryTimeoutMillis:
|
||||||
|
connection.query_timeout != null
|
||||||
|
? connection.query_timeout * 1000
|
||||||
|
: undefined,
|
||||||
|
ssl:
|
||||||
|
connection.ssl_enabled === "Y"
|
||||||
|
? { rejectUnauthorized: false }
|
||||||
|
: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커넥터 생성
|
||||||
|
const connector = await DatabaseConnectorFactory.createConnector(
|
||||||
|
connection.db_type,
|
||||||
|
config,
|
||||||
|
connectionId
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const columns = await connector.getColumns(tableName);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: columns,
|
||||||
|
message: `${columns.length}개의 컬럼을 조회했습니다.`,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await DatabaseConnectorFactory.closeConnector(
|
||||||
|
connectionId,
|
||||||
|
connection.db_type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("컬럼 목록 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "컬럼 목록 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { query, queryOne } from "../database/db";
|
||||||
import prisma from "../config/database";
|
|
||||||
import {
|
import {
|
||||||
CreateLayoutRequest,
|
CreateLayoutRequest,
|
||||||
UpdateLayoutRequest,
|
UpdateLayoutRequest,
|
||||||
|
|
@ -77,42 +76,59 @@ export class LayoutService {
|
||||||
|
|
||||||
const skip = (page - 1) * size;
|
const skip = (page - 1) * size;
|
||||||
|
|
||||||
// 검색 조건 구성
|
// 동적 WHERE 조건 구성
|
||||||
const where: any = {
|
const whereConditions: string[] = ["is_active = $1"];
|
||||||
is_active: "Y",
|
const values: any[] = ["Y"];
|
||||||
OR: [
|
let paramIndex = 2;
|
||||||
{ company_code: companyCode },
|
|
||||||
...(includePublic ? [{ is_public: "Y" }] : []),
|
// company_code OR is_public 조건
|
||||||
],
|
if (includePublic) {
|
||||||
};
|
whereConditions.push(
|
||||||
|
`(company_code = $${paramIndex} OR is_public = $${paramIndex + 1})`
|
||||||
|
);
|
||||||
|
values.push(companyCode, "Y");
|
||||||
|
paramIndex += 2;
|
||||||
|
} else {
|
||||||
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
|
values.push(companyCode);
|
||||||
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
where.category = category;
|
whereConditions.push(`category = $${paramIndex++}`);
|
||||||
|
values.push(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layoutType) {
|
if (layoutType) {
|
||||||
where.layout_type = layoutType;
|
whereConditions.push(`layout_type = $${paramIndex++}`);
|
||||||
|
values.push(layoutType);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
where.OR = [
|
whereConditions.push(
|
||||||
...where.OR,
|
`(layout_name ILIKE $${paramIndex} OR layout_name_eng ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
|
||||||
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
|
);
|
||||||
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } },
|
values.push(`%${searchTerm}%`);
|
||||||
{ description: { contains: searchTerm, mode: "insensitive" } },
|
paramIndex++;
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||||
prisma.layout_standards.findMany({
|
|
||||||
where,
|
const [data, countResult] = await Promise.all([
|
||||||
skip,
|
query<any>(
|
||||||
take: size,
|
`SELECT * FROM layout_standards
|
||||||
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }],
|
${whereClause}
|
||||||
}),
|
ORDER BY sort_order ASC, created_date DESC
|
||||||
prisma.layout_standards.count({ where }),
|
LIMIT $${paramIndex++} OFFSET $${paramIndex++}`,
|
||||||
|
[...values, size, skip]
|
||||||
|
),
|
||||||
|
queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM layout_standards ${whereClause}`,
|
||||||
|
values
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult?.count || "0");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data.map(
|
data: data.map(
|
||||||
(layout) =>
|
(layout) =>
|
||||||
|
|
@ -149,13 +165,13 @@ export class LayoutService {
|
||||||
layoutCode: string,
|
layoutCode: string,
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<LayoutStandard | null> {
|
): Promise<LayoutStandard | null> {
|
||||||
const layout = await prisma.layout_standards.findFirst({
|
const layout = await queryOne<any>(
|
||||||
where: {
|
`SELECT * FROM layout_standards
|
||||||
layout_code: layoutCode,
|
WHERE layout_code = $1 AND is_active = $2
|
||||||
is_active: "Y",
|
AND (company_code = $3 OR is_public = $4)
|
||||||
OR: [{ company_code: companyCode }, { is_public: "Y" }],
|
LIMIT 1`,
|
||||||
},
|
[layoutCode, "Y", companyCode, "Y"]
|
||||||
});
|
);
|
||||||
|
|
||||||
if (!layout) return null;
|
if (!layout) return null;
|
||||||
|
|
||||||
|
|
@ -196,24 +212,31 @@ export class LayoutService {
|
||||||
companyCode
|
companyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
const layout = await prisma.layout_standards.create({
|
const layout = await queryOne<any>(
|
||||||
data: {
|
`INSERT INTO layout_standards
|
||||||
layout_code: layoutCode,
|
(layout_code, layout_name, layout_name_eng, description, layout_type, category,
|
||||||
layout_name: request.layoutName,
|
icon_name, default_size, layout_config, zones_config, is_public, is_active,
|
||||||
layout_name_eng: request.layoutNameEng,
|
company_code, created_by, updated_by, created_date, updated_date, sort_order)
|
||||||
description: request.description,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW(), 0)
|
||||||
layout_type: request.layoutType,
|
RETURNING *`,
|
||||||
category: request.category,
|
[
|
||||||
icon_name: request.iconName,
|
layoutCode,
|
||||||
default_size: safeJSONStringify(request.defaultSize) as any,
|
request.layoutName,
|
||||||
layout_config: safeJSONStringify(request.layoutConfig) as any,
|
request.layoutNameEng,
|
||||||
zones_config: safeJSONStringify(request.zonesConfig) as any,
|
request.description,
|
||||||
is_public: request.isPublic ? "Y" : "N",
|
request.layoutType,
|
||||||
company_code: companyCode,
|
request.category,
|
||||||
created_by: userId,
|
request.iconName,
|
||||||
updated_by: userId,
|
safeJSONStringify(request.defaultSize),
|
||||||
},
|
safeJSONStringify(request.layoutConfig),
|
||||||
});
|
safeJSONStringify(request.zonesConfig),
|
||||||
|
request.isPublic ? "Y" : "N",
|
||||||
|
"Y",
|
||||||
|
companyCode,
|
||||||
|
userId,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return this.mapToLayoutStandard(layout);
|
return this.mapToLayoutStandard(layout);
|
||||||
}
|
}
|
||||||
|
|
@ -227,47 +250,69 @@ export class LayoutService {
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<LayoutStandard | null> {
|
): Promise<LayoutStandard | null> {
|
||||||
// 수정 권한 확인
|
// 수정 권한 확인
|
||||||
const existing = await prisma.layout_standards.findFirst({
|
const existing = await queryOne<any>(
|
||||||
where: {
|
`SELECT * FROM layout_standards
|
||||||
layout_code: request.layoutCode,
|
WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
|
||||||
company_code: companyCode,
|
[request.layoutCode, companyCode, "Y"]
|
||||||
is_active: "Y",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
|
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {
|
// 동적 UPDATE 쿼리 생성
|
||||||
updated_by: userId,
|
const updateFields: string[] = ["updated_by = $1", "updated_date = NOW()"];
|
||||||
updated_date: new Date(),
|
const values: any[] = [userId];
|
||||||
};
|
let paramIndex = 2;
|
||||||
|
|
||||||
// 수정할 필드만 업데이트
|
if (request.layoutName !== undefined) {
|
||||||
if (request.layoutName !== undefined)
|
updateFields.push(`layout_name = $${paramIndex++}`);
|
||||||
updateData.layout_name = request.layoutName;
|
values.push(request.layoutName);
|
||||||
if (request.layoutNameEng !== undefined)
|
}
|
||||||
updateData.layout_name_eng = request.layoutNameEng;
|
if (request.layoutNameEng !== undefined) {
|
||||||
if (request.description !== undefined)
|
updateFields.push(`layout_name_eng = $${paramIndex++}`);
|
||||||
updateData.description = request.description;
|
values.push(request.layoutNameEng);
|
||||||
if (request.layoutType !== undefined)
|
}
|
||||||
updateData.layout_type = request.layoutType;
|
if (request.description !== undefined) {
|
||||||
if (request.category !== undefined) updateData.category = request.category;
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
if (request.iconName !== undefined) updateData.icon_name = request.iconName;
|
values.push(request.description);
|
||||||
if (request.defaultSize !== undefined)
|
}
|
||||||
updateData.default_size = safeJSONStringify(request.defaultSize) as any;
|
if (request.layoutType !== undefined) {
|
||||||
if (request.layoutConfig !== undefined)
|
updateFields.push(`layout_type = $${paramIndex++}`);
|
||||||
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any;
|
values.push(request.layoutType);
|
||||||
if (request.zonesConfig !== undefined)
|
}
|
||||||
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any;
|
if (request.category !== undefined) {
|
||||||
if (request.isPublic !== undefined)
|
updateFields.push(`category = $${paramIndex++}`);
|
||||||
updateData.is_public = request.isPublic ? "Y" : "N";
|
values.push(request.category);
|
||||||
|
}
|
||||||
|
if (request.iconName !== undefined) {
|
||||||
|
updateFields.push(`icon_name = $${paramIndex++}`);
|
||||||
|
values.push(request.iconName);
|
||||||
|
}
|
||||||
|
if (request.defaultSize !== undefined) {
|
||||||
|
updateFields.push(`default_size = $${paramIndex++}`);
|
||||||
|
values.push(safeJSONStringify(request.defaultSize));
|
||||||
|
}
|
||||||
|
if (request.layoutConfig !== undefined) {
|
||||||
|
updateFields.push(`layout_config = $${paramIndex++}`);
|
||||||
|
values.push(safeJSONStringify(request.layoutConfig));
|
||||||
|
}
|
||||||
|
if (request.zonesConfig !== undefined) {
|
||||||
|
updateFields.push(`zones_config = $${paramIndex++}`);
|
||||||
|
values.push(safeJSONStringify(request.zonesConfig));
|
||||||
|
}
|
||||||
|
if (request.isPublic !== undefined) {
|
||||||
|
updateFields.push(`is_public = $${paramIndex++}`);
|
||||||
|
values.push(request.isPublic ? "Y" : "N");
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.layout_standards.update({
|
const updated = await queryOne<any>(
|
||||||
where: { layout_code: request.layoutCode },
|
`UPDATE layout_standards
|
||||||
data: updateData,
|
SET ${updateFields.join(", ")}
|
||||||
});
|
WHERE layout_code = $${paramIndex}
|
||||||
|
RETURNING *`,
|
||||||
|
[...values, request.layoutCode]
|
||||||
|
);
|
||||||
|
|
||||||
return this.mapToLayoutStandard(updated);
|
return this.mapToLayoutStandard(updated);
|
||||||
}
|
}
|
||||||
|
|
@ -280,26 +325,22 @@ export class LayoutService {
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const existing = await prisma.layout_standards.findFirst({
|
const existing = await queryOne<any>(
|
||||||
where: {
|
`SELECT * FROM layout_standards
|
||||||
layout_code: layoutCode,
|
WHERE layout_code = $1 AND company_code = $2 AND is_active = $3`,
|
||||||
company_code: companyCode,
|
[layoutCode, companyCode, "Y"]
|
||||||
is_active: "Y",
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
|
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.layout_standards.update({
|
await query(
|
||||||
where: { layout_code: layoutCode },
|
`UPDATE layout_standards
|
||||||
data: {
|
SET is_active = $1, updated_by = $2, updated_date = NOW()
|
||||||
is_active: "N",
|
WHERE layout_code = $3`,
|
||||||
updated_by: userId,
|
["N", userId, layoutCode]
|
||||||
updated_date: new Date(),
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -342,20 +383,17 @@ export class LayoutService {
|
||||||
async getLayoutCountsByCategory(
|
async getLayoutCountsByCategory(
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<Record<string, number>> {
|
): Promise<Record<string, number>> {
|
||||||
const counts = await prisma.layout_standards.groupBy({
|
const counts = await query<{ category: string; count: string }>(
|
||||||
by: ["category"],
|
`SELECT category, COUNT(*) as count
|
||||||
_count: {
|
FROM layout_standards
|
||||||
layout_code: true,
|
WHERE is_active = $1 AND (company_code = $2 OR is_public = $3)
|
||||||
},
|
GROUP BY category`,
|
||||||
where: {
|
["Y", companyCode, "Y"]
|
||||||
is_active: "Y",
|
);
|
||||||
OR: [{ company_code: companyCode }, { is_public: "Y" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return counts.reduce(
|
return counts.reduce(
|
||||||
(acc: Record<string, number>, item: any) => {
|
(acc: Record<string, number>, item: any) => {
|
||||||
acc[item.category] = item._count.layout_code;
|
acc[item.category] = parseInt(item.count);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>
|
{} as Record<string, number>
|
||||||
|
|
@ -370,16 +408,11 @@ export class LayoutService {
|
||||||
companyCode: string
|
companyCode: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
|
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
|
||||||
const existingCodes = await prisma.layout_standards.findMany({
|
const existingCodes = await query<{ layout_code: string }>(
|
||||||
where: {
|
`SELECT layout_code FROM layout_standards
|
||||||
layout_code: {
|
WHERE layout_code LIKE $1`,
|
||||||
startsWith: prefix,
|
[`${prefix}%`]
|
||||||
},
|
);
|
||||||
},
|
|
||||||
select: {
|
|
||||||
layout_code: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const maxNumber = existingCodes.reduce((max: number, item: any) => {
|
const maxNumber = existingCodes.reduce((max: number, item: any) => {
|
||||||
const match = item.layout_code.match(/_(\d+)$/);
|
const match = item.layout_code.match(/_(\d+)$/);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { encryptionService } from './encryptionService';
|
||||||
|
|
||||||
|
export interface MailAccount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpSecure: boolean;
|
||||||
|
smtpUsername: string;
|
||||||
|
smtpPassword: string; // 암호화된 비밀번호
|
||||||
|
dailyLimit: number;
|
||||||
|
status: 'active' | 'inactive' | 'suspended';
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MailAccountFileService {
|
||||||
|
private accountsDir: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
|
||||||
|
this.ensureDirectoryExists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDirectoryExists() {
|
||||||
|
try {
|
||||||
|
await fs.access(this.accountsDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccountPath(id: string): string {
|
||||||
|
return path.join(this.accountsDir, `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllAccounts(): Promise<MailAccount[]> {
|
||||||
|
await this.ensureDirectoryExists();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(this.accountsDir);
|
||||||
|
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||||
|
|
||||||
|
const accounts = await Promise.all(
|
||||||
|
jsonFiles.map(async (file) => {
|
||||||
|
const content = await fs.readFile(
|
||||||
|
path.join(this.accountsDir, file),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
return JSON.parse(content) as MailAccount;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return accounts.sort((a, b) =>
|
||||||
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountById(id: string): Promise<MailAccount | null> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(
|
||||||
|
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'>
|
||||||
|
): Promise<MailAccount> {
|
||||||
|
const id = `account-${Date.now()}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// 비밀번호 암호화
|
||||||
|
const encryptedPassword = encryptionService.encrypt(data.smtpPassword);
|
||||||
|
|
||||||
|
const account: MailAccount = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
smtpPassword: encryptedPassword,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
this.getAccountPath(id),
|
||||||
|
JSON.stringify(account, null, 2),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>>
|
||||||
|
): Promise<MailAccount | null> {
|
||||||
|
const existing = await this.getAccountById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호가 변경되면 암호화
|
||||||
|
if (data.smtpPassword && data.smtpPassword !== existing.smtpPassword) {
|
||||||
|
data.smtpPassword = encryptionService.encrypt(data.smtpPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated: MailAccount = {
|
||||||
|
...existing,
|
||||||
|
...data,
|
||||||
|
id: existing.id,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
this.getAccountPath(id),
|
||||||
|
JSON.stringify(updated, null, 2),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(id: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.getAccountPath(id));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountByEmail(email: string): Promise<MailAccount | null> {
|
||||||
|
const accounts = await this.getAllAccounts();
|
||||||
|
return accounts.find(a => a.email === email) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveAccounts(): Promise<MailAccount[]> {
|
||||||
|
const accounts = await this.getAllAccounts();
|
||||||
|
return accounts.filter(a => a.status === 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비밀번호 복호화
|
||||||
|
*/
|
||||||
|
decryptPassword(encryptedPassword: string): string {
|
||||||
|
return encryptionService.decrypt(encryptedPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailAccountFileService = new MailAccountFileService();
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue