729 all

------------------------------ .gitignore ------------------------------ .next node_modules dist ------------------------------ ------------------------------ .node-version ------------------------------ 16.18.0 ------------------------------ ------------------------------ .nvmrc ------------------------------ 16.18.0 ------------------------------ ------------------------------ app\api\crypto-type-handler-mappings\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { CryptoTypeHandlerMapping } from '../../lib/types' const dataDir = path.join(process.cwd(), 'data') const mappingPath = path.join(dataDir, 'handler-mapping.json') const legacyMappingPath = path.join(dataDir, 'crypto-type-handler-mappings.json') function normalizeMapping(mapping: Partial): CryptoTypeHandlerMapping { return { cryptoType: (mapping.cryptoType ?? '').trim(), encryptTypeHandler: (mapping.encryptTypeHandler ?? '').trim(), hashTypeHandler: (mapping.hashTypeHandler ?? '').trim(), } } function uniqueMappings(mappings: Array>) { const keyed = new Map() const keyless: CryptoTypeHandlerMapping[] = [] for (const item of mappings) { const mapping = normalizeMapping(item) const key = mapping.cryptoType.trim().toUpperCase() if (!key) { if (mapping.encryptTypeHandler || mapping.hashTypeHandler) keyless.push(mapping) continue } keyed.set(key, mapping) } return [...keyed.values(), ...keyless] } async function ensureFile() { await fs.mkdir(dataDir, { recursive: true }) try { await fs.access(mappingPath) } catch { try { const legacyText = await fs.readFile(legacyMappingPath, 'utf-8') await fs.writeFile(mappingPath, legacyText, 'utf-8') } catch { await fs.writeFile(mappingPath, '[]', 'utf-8') } } } export async function GET() { await ensureFile() const text = await fs.readFile(mappingPath, 'utf-8') return NextResponse.json(uniqueMappings(JSON.parse(text))) } export async function POST(request: Request) { const mappings = await request.json() as CryptoTypeHandlerMapping[] if (!Array.isArray(mappings)) { return NextResponse.json({ message: 'cryptoType handler mapping JSON must be an array.' }, { status: 400 }) } await ensureFile() const unique = uniqueMappings(mappings) await fs.writeFile(mappingPath, JSON.stringify(unique, null, 2), 'utf-8') return NextResponse.json(unique) } ------------------------------ ------------------------------ app\api\db-add-crypto-columns\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import oracledb from 'oracledb' import type { CryptoTarget } from '../../lib/types' export const runtime = 'nodejs' type DbIni = { ip?: string port?: string sid?: string id?: string pass?: string } type ColumnKind = 'encrypt' | 'hash' type ColumnAction = 'add' | 'drop' type ColumnMeta = { TABLE_NAME: string COLUMN_NAME: string DATA_TYPE: string DATA_LENGTH: number CHAR_LENGTH: number | null CHAR_USED: string | null } const dbIniPath = path.join(process.cwd(), 'db.ini') function parseIni(text: string): DbIni { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && !line.startsWith(';')) .reduce((acc, line) => { const splitAt = line.indexOf('=') if (splitAt < 0) return acc const key = line.slice(0, splitAt).trim().toLowerCase() const value = line.slice(splitAt + 1).trim() if (['ip', 'port', 'sid', 'id', 'pass'].includes(key)) { acc[key as keyof DbIni] = value } return acc }, {}) } async function readDbIni() { const config = parseIni(await fs.readFile(dbIniPath, 'utf8')) const missing = ['ip', 'port', 'sid', 'id', 'pass'].filter((key) => !config[key as keyof DbIni]) if (missing.length > 0) { throw new Error(`db.ini 필수 항목 누락: ${missing.join(', ')}`) } return config as Required } function normalizeIdentifier(value: string | undefined) { return (value ?? '').trim().replace(/^"|"$/g, '').toUpperCase() } function assertSafeIdentifier(value: string, label: string) { if (!/^[A-Z_][A-Z0-9_$#]*$/.test(value)) { throw new Error(`안전하지 않은 ${label}: ${value}`) } } function createColumnType(source: ColumnMeta) { const sourceLength = source.CHAR_USED ? Number(source.CHAR_LENGTH ?? source.DATA_LENGTH) : Number(source.DATA_LENGTH) const nextLength = Math.max(1, sourceLength * 3) return source.CHAR_USED ? `VARCHAR2(${nextLength} CHAR)` : `VARCHAR2(${nextLength})` } async function findColumn(connection: oracledb.Connection, owner: string, tableName: string, columnName: string) { const result = await connection.execute( `select table_name, column_name, data_type, data_length, char_length, char_used from all_tab_columns where owner = :owner and table_name = :tableName and column_name = :columnName`, { owner, tableName, columnName }, { outFormat: oracledb.OUT_FORMAT_OBJECT }, ) return result.rows?.[0] } export async function POST(request: Request) { const body = await request.json() as { action?: ColumnAction, kind?: ColumnKind, targets?: CryptoTarget[] } const action = body.action ?? 'add' const kind = body.kind const targets = Array.isArray(body.targets) ? body.targets : [] if (action !== 'add' && action !== 'drop') { return NextResponse.json({ message: 'action은 add 또는 drop 이어야 합니다.' }, { status: 400 }) } if (kind !== 'encrypt' && kind !== 'hash') { return NextResponse.json({ message: 'kind는 encrypt 또는 hash 여야 합니다.' }, { status: 400 }) } if (targets.length === 0) { return NextResponse.json({ message: '처리할 암호화 대상이 없습니다.' }, { status: 400 }) } let connection: oracledb.Connection | undefined try { const db = await readDbIni() const owner = db.id.toUpperCase() connection = await oracledb.getConnection({ user: db.id, password: db.pass, connectString: `${db.ip}:${db.port}/${db.sid}`, }) const results = [] for (const target of targets) { const tableName = normalizeIdentifier(target.tableName) const plainColumn = normalizeIdentifier(target.plainColumn) const newColumn = normalizeIdentifier(kind === 'encrypt' ? target.encColumn : target.hashColumn) if (!tableName || !newColumn || (action === 'add' && !plainColumn)) { results.push({ tableName, plainColumn, newColumn, status: 'skipped', message: '필수 컬럼 정보가 비어 있습니다.' }) continue } try { assertSafeIdentifier(tableName, '테이블명') if (plainColumn) assertSafeIdentifier(plainColumn, '원컬럼명') assertSafeIdentifier(newColumn, '추가컬럼명') const existing = await findColumn(connection, owner, tableName, newColumn) if (action === 'add' && existing) { results.push({ tableName, plainColumn, newColumn, status: 'skipped', message: '이미 존재하는 컬럼입니다.' }) continue } if (action === 'drop' && !existing) { results.push({ tableName, plainColumn, newColumn, status: 'skipped', message: '이미 없는 컬럼입니다.' }) continue } if (action === 'drop') { const ddl = `alter table ${tableName} drop column ${newColumn}` await connection.execute(ddl) results.push({ tableName, plainColumn, newColumn, status: 'dropped', ddl }) continue } const source = await findColumn(connection, owner, tableName, plainColumn) if (!source) { results.push({ tableName, plainColumn, newColumn, status: 'failed', message: '원컬럼을 찾지 못했습니다.' }) continue } const columnType = createColumnType(source) const ddl = `alter table ${tableName} add (${newColumn} ${columnType})` await connection.execute(ddl) results.push({ tableName, plainColumn, newColumn, status: 'created', columnType, ddl }) } catch (error) { results.push({ tableName, plainColumn, newColumn, status: 'failed', message: error instanceof Error ? error.message : '컬럼 DDL 실패', }) } } return NextResponse.json({ action, kind, results, summary: { created: results.filter((result) => result.status === 'created').length, dropped: results.filter((result) => result.status === 'dropped').length, skipped: results.filter((result) => result.status === 'skipped').length, failed: results.filter((result) => result.status === 'failed').length, }, }) } catch (error) { return NextResponse.json( { message: error instanceof Error ? error.message : '컬럼 일괄 DDL 실패' }, { status: 500 }, ) } finally { await connection?.close().catch(() => undefined) } } ------------------------------ ------------------------------ app\api\db-column-length\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import oracledb from 'oracledb' export const runtime = 'nodejs' type DbIni = { ip?: string port?: string sid?: string id?: string pass?: string } const dbIniPath = path.join(process.cwd(), 'db.ini') function parseIni(text: string): DbIni { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && !line.startsWith(';')) .reduce((acc, line) => { const splitAt = line.indexOf('=') if (splitAt < 0) return acc const key = line.slice(0, splitAt).trim().toLowerCase() const value = line.slice(splitAt + 1).trim() if (['ip', 'port', 'sid', 'id', 'pass'].includes(key)) { acc[key as keyof DbIni] = value } return acc }, {}) } async function readDbIni() { const config = parseIni(await fs.readFile(dbIniPath, 'utf8')) const missing = ['ip', 'port', 'sid', 'id', 'pass'].filter((key) => !config[key as keyof DbIni]) if (missing.length > 0) { throw new Error(`db.ini 필수 항목 누락: ${missing.join(', ')}`) } return config as Required } function normalizeIdentifier(value: string | null) { return (value ?? '').trim().replace(/^"|"$/g, '').toUpperCase() } export async function GET(request: NextRequest) { const table = normalizeIdentifier(request.nextUrl.searchParams.get('table')) const column = normalizeIdentifier(request.nextUrl.searchParams.get('column')) if (!table || !column) { return NextResponse.json({ message: 'table, column 파라미터가 필요합니다.' }, { status: 400 }) } let connection: oracledb.Connection | undefined try { const db = await readDbIni() connection = await oracledb.getConnection({ user: db.id, password: db.pass, connectString: `${db.ip}:${db.port}/${db.sid}`, }) const result = await connection.execute<{ OWNER: string TABLE_NAME: string COLUMN_NAME: string DATA_TYPE: string DATA_LENGTH: number CHAR_LENGTH: number | null CHAR_USED: string | null }>( `select owner, table_name, column_name, data_type, data_length, char_length, char_used from all_tab_columns where table_name = :tableName and column_name = :columnName and owner = :owner order by column_id`, { tableName: table, columnName: column, owner: db.id.toUpperCase(), }, { outFormat: oracledb.OUT_FORMAT_OBJECT }, ) const row = result.rows?.[0] if (!row) { return NextResponse.json({ message: `${table}.${column} 컬럼을 찾지 못했습니다.` }, { status: 404 }) } const maxLength = row.CHAR_USED ? Number(row.CHAR_LENGTH ?? row.DATA_LENGTH) : Number(row.DATA_LENGTH) const size = row.CHAR_USED ? `${maxLength} CHAR` : String(maxLength) return NextResponse.json({ tableName: row.TABLE_NAME, columnName: row.COLUMN_NAME, dataType: row.DATA_TYPE, dataLength: row.DATA_LENGTH, charLength: row.CHAR_LENGTH, charUsed: row.CHAR_USED, maxLength, size, typeLabel: `${row.DATA_TYPE}(${size})`, }) } catch (error) { return NextResponse.json( { message: error instanceof Error ? error.message : 'DB 컬럼 길이 조회 실패' }, { status: 500 }, ) } finally { await connection?.close().catch(() => undefined) } } ------------------------------ ------------------------------ app\api\db-random-column-values\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import oracledb from 'oracledb' export const runtime = 'nodejs' type DbIni = { ip?: string port?: string sid?: string id?: string pass?: string } const dbIniPath = path.join(process.cwd(), 'db.ini') function parseIni(text: string): DbIni { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && !line.startsWith(';')) .reduce((acc, line) => { const splitAt = line.indexOf('=') if (splitAt < 0) return acc const key = line.slice(0, splitAt).trim().toLowerCase() const value = line.slice(splitAt + 1).trim() if (['ip', 'port', 'sid', 'id', 'pass'].includes(key)) { acc[key as keyof DbIni] = value } return acc }, {}) } async function readDbIni() { const config = parseIni(await fs.readFile(dbIniPath, 'utf8')) const missing = ['ip', 'port', 'sid', 'id', 'pass'].filter((key) => !config[key as keyof DbIni]) if (missing.length > 0) { throw new Error(`db.ini 필수 항목 누락: ${missing.join(', ')}`) } return config as Required } function normalizeIdentifier(value: string | null) { return (value ?? '').trim().replace(/^"|"$/g, '').toUpperCase() } function assertSafeIdentifier(value: string, label: string) { if (!/^[A-Z_][A-Z0-9_$#]*$/.test(value)) { throw new Error(`안전하지 않은 ${label}: ${value}`) } } export async function GET(request: NextRequest) { const table = normalizeIdentifier(request.nextUrl.searchParams.get('table')) const column = normalizeIdentifier(request.nextUrl.searchParams.get('column')) if (!table || !column) { return NextResponse.json({ message: 'table, column 파라미터가 필요합니다.' }, { status: 400 }) } let connection: oracledb.Connection | undefined try { assertSafeIdentifier(table, '테이블명') assertSafeIdentifier(column, '컬럼명') const db = await readDbIni() connection = await oracledb.getConnection({ user: db.id, password: db.pass, connectString: `${db.ip}:${db.port}/${db.sid}`, }) const result = await connection.execute<{ VALUE: unknown }>( `select value from ( select to_char(${column}) as value from ${table} where ${column} is not null ) where rownum <= 3`, {}, { outFormat: oracledb.OUT_FORMAT_OBJECT }, ) return NextResponse.json({ tableName: table, columnName: column, values: (result.rows ?? []).map((row) => String(row.VALUE ?? '')), }) } catch (error) { return NextResponse.json( { message: error instanceof Error ? error.message : 'TOP 3 컬럼 값 조회 실패' }, { status: 500 }, ) } finally { await connection?.close().catch(() => undefined) } } ------------------------------ ------------------------------ app\api\db-table-primary-key\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import oracledb from 'oracledb' export const runtime = 'nodejs' type DbIni = { ip?: string port?: string sid?: string id?: string pass?: string } const dbIniPath = path.join(process.cwd(), 'db.ini') function parseIni(text: string): DbIni { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && !line.startsWith(';')) .reduce((acc, line) => { const splitAt = line.indexOf('=') if (splitAt < 0) return acc const key = line.slice(0, splitAt).trim().toLowerCase() const value = line.slice(splitAt + 1).trim() if (['ip', 'port', 'sid', 'id', 'pass'].includes(key)) { acc[key as keyof DbIni] = value } return acc }, {}) } async function readDbIni() { const config = parseIni(await fs.readFile(dbIniPath, 'utf8')) const missing = ['ip', 'port', 'sid', 'id', 'pass'].filter((key) => !config[key as keyof DbIni]) if (missing.length > 0) { throw new Error(`db.ini 필수 항목 누락: ${missing.join(', ')}`) } return config as Required } function normalizeIdentifier(value: string | null) { return (value ?? '').trim().replace(/^"|"$/g, '').toUpperCase() } export async function GET(request: NextRequest) { const table = normalizeIdentifier(request.nextUrl.searchParams.get('table')) if (!table) { return NextResponse.json({ message: 'table 파라미터가 필요합니다.' }, { status: 400 }) } let connection: oracledb.Connection | undefined try { const db = await readDbIni() connection = await oracledb.getConnection({ user: db.id, password: db.pass, connectString: `${db.ip}:${db.port}/${db.sid}`, }) const result = await connection.execute<{ COLUMN_NAME: string DATA_TYPE: string DATA_LENGTH: number CHAR_LENGTH: number | null CHAR_USED: string | null POSITION: number }>( `select cols.column_name, tc.data_type, tc.data_length, tc.char_length, tc.char_used, cols.position from all_constraints cons join all_cons_columns cols on cons.owner = cols.owner and cons.constraint_name = cols.constraint_name and cons.table_name = cols.table_name join all_tab_columns tc on tc.owner = cols.owner and tc.table_name = cols.table_name and tc.column_name = cols.column_name where cons.constraint_type = 'P' and cons.owner = :owner and cons.table_name = :tableName order by cols.position`, { owner: db.id.toUpperCase(), tableName: table, }, { outFormat: oracledb.OUT_FORMAT_OBJECT }, ) return NextResponse.json({ tableName: table, columns: (result.rows ?? []).map((row) => ({ columnName: row.COLUMN_NAME, dataType: row.DATA_TYPE, dataLength: row.DATA_LENGTH, charLength: row.CHAR_LENGTH, charUsed: row.CHAR_USED, position: row.POSITION, })), }) } catch (error) { return NextResponse.json( { message: error instanceof Error ? error.message : 'PK 메타데이터 조회 실패' }, { status: 500 }, ) } finally { await connection?.close().catch(() => undefined) } } ------------------------------ ------------------------------ app\api\mapper-rewrite\candidates\route.ts ------------------------------ import { NextResponse } from 'next/server' export async function GET() { return NextResponse.json([]) } ------------------------------ ------------------------------ app\api\mapper-rewrite\preview\route.ts ------------------------------ import { NextResponse } from 'next/server' import type { RewriteCandidate } from '../../../lib/types' export async function POST(request: Request) { const payload = await request.json() as Partial return NextResponse.json({ mapperFile: payload.mapperFile ?? '', sqlId: payload.sqlId ?? '', rewriteType: payload.rewriteType ?? 'LOCAL_REWRITE', riskLevel: 'LOW', beforeSql: payload.beforeSql ?? '', afterSql: payload.afterSql ?? payload.beforeSql ?? '', diff: '', warnings: ['No local preview candidate was supplied.'], supportSnippets: payload.supportSnippets ?? [], } satisfies RewriteCandidate) } ------------------------------ ------------------------------ app\api\mapper-rewrite\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { RewriteCandidate, VerificationSettings } from '../../lib/types' const settingsPath = path.join(process.cwd(), 'data', 'verification-settings.json') const defaultMapperRoot = '../target-spring-oracle/src/main/resources/mapper/business' async function loadSettings(): Promise { try { const text = await fs.readFile(settingsPath, 'utf-8') return JSON.parse(text) as VerificationSettings } catch { return { mapperRoot: defaultMapperRoot } } } async function resolveMapperRoot(mapperRoot: string) { const candidates = [ path.resolve(mapperRoot), path.resolve(process.cwd(), mapperRoot), path.resolve(process.cwd(), '..', mapperRoot), path.resolve(process.cwd(), '..', 'target-spring-oracle', mapperRoot), ] for (const candidate of candidates) { try { const stat = await fs.stat(candidate) if (stat.isDirectory()) return candidate } catch { // try next } } throw new Error(`Mapper XML root not found: ${mapperRoot}`) } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function sqlTagPattern(sqlId: string) { return new RegExp( `<(select|insert|update|delete)\\b([^>]*\\bid\\s*=\\s*['"]${escapeRegExp(sqlId)}['"][^>]*)>[\\s\\S]*?<\\/\\1>`, 'i', ) } function snippetId(snippet: string) { return snippet.match(/\bid\s*=\s*['"]([^'"]+)['"]/i)?.[1] } function hasSnippet(xml: string, snippet: string) { const id = snippetId(snippet) if (id && new RegExp(`\\bid\\s*=\\s*['"]${escapeRegExp(id)}['"]`, 'i').test(xml)) return true return xml.includes(snippet.trim()) } function insertSupportSnippets(xml: string, snippets: string[] = []) { const missing = snippets.map((snippet) => snippet.trim()).filter((snippet) => snippet && !hasSnippet(xml, snippet)) if (missing.length === 0) return xml return xml.replace(/(]*>\s*)/i, `$1\n ${missing.join('\n\n ')}\n`) } function makeSimpleDiff(before: string, after: string) { const left = before.split(/\r?\n/) const right = after.split(/\r?\n/) const lines: string[] = [] const max = Math.max(left.length, right.length) for (let index = 0; index < max; index += 1) { const beforeLine = left[index] ?? '' const afterLine = right[index] ?? '' if (beforeLine !== afterLine) { lines.push(`- ${beforeLine}`) lines.push(`+ ${afterLine}`) } } return lines.join('\n') } async function mapperPath(mapperFile: string) { if (!mapperFile?.trim()) throw new Error('mapperFile is required.') const settings = await loadSettings() const root = await resolveMapperRoot(settings.mapperRoot) const file = path.resolve(root, mapperFile) const relative = path.relative(root, file) if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error('Mapper path is outside configured root.') } return file } export async function POST(request: Request) { try { const payload = await request.json() as RewriteCandidate if (!payload.sqlId?.trim()) throw new Error('sqlId is required.') if (!payload.afterSql?.trim()) throw new Error('afterSql is required.') const file = await mapperPath(payload.mapperFile) const beforeXml = await fs.readFile(file, 'utf-8') const pattern = sqlTagPattern(payload.sqlId) const beforeMatch = beforeXml.match(pattern) if (!beforeMatch) { throw new Error(`Mapper item not found: ${payload.mapperFile}.${payload.sqlId}`) } let nextXml = beforeXml.replace(pattern, payload.afterSql.trim()) nextXml = insertSupportSnippets(nextXml, payload.supportSnippets) const backup = `${file}.${new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14)}.bak` await fs.copyFile(file, backup) await fs.writeFile(file, nextXml, 'utf-8') return NextResponse.json({ ...payload, beforeSql: beforeMatch[0], afterSql: payload.afterSql.trim(), diff: makeSimpleDiff(beforeXml, nextXml), warnings: [...(payload.warnings ?? []), `backup: ${backup}`], } satisfies RewriteCandidate) } catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : 'Mapper rewrite failed', }, { status: 400 }) } } ------------------------------ ------------------------------ app\api\mapper-sql-ids\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { MapperSqlItem, VerificationSettings } from '../../lib/types' const settingsPath = path.join(process.cwd(), 'data', 'verification-settings.json') const defaultMapperRoot = '../target-spring-oracle/src/main/resources/mapper/business' const sqlTagPattern = /<(select|insert|update|delete)\b[^>]*\bid\s*=\s*['"]([^'"]+)['"][\s\S]*?<\/\1>/gi const sqlFragmentPattern = /]*\bid\s*=\s*['"]([^'"]+)['"][\s\S]*?<\/sql>/gi const mapperNamespacePattern = /]*\bnamespace\s*=\s*['"]([^'"]+)['"][^>]*>/i const includePattern = /]*\brefid\s*=\s*['"]([^'"]+)['"][^>]*\/?>/gi type MapperFileContext = { file: string mapperFile: string namespace: string xml: string } async function loadSettings(): Promise { try { const text = await fs.readFile(settingsPath, 'utf-8') return JSON.parse(text) as VerificationSettings } catch { return { mapperRoot: defaultMapperRoot } } } async function resolveMapperRoot(mapperRoot: string) { const candidates = [ path.resolve(mapperRoot), path.resolve(process.cwd(), mapperRoot), path.resolve(process.cwd(), '..', mapperRoot), path.resolve(process.cwd(), '..', 'target-spring-oracle', mapperRoot), ] for (const candidate of candidates) { try { const stat = await fs.stat(candidate) if (stat.isDirectory()) { return candidate } } catch { // try next candidate } } throw new Error(`Mapper XML 루트 경로를 찾을 수 없습니다: ${mapperRoot}`) } async function collectXmlFiles(root: string): Promise { const entries = await fs.readdir(root, { withFileTypes: true }) const files = await Promise.all(entries.map(async (entry) => { const fullPath = path.join(root, entry.name) if (entry.isDirectory()) { return collectXmlFiles(fullPath) } return entry.isFile() && entry.name.endsWith('.xml') ? [fullPath] : [] })) return files.flat() } function stripSqlTag(fragment: string) { return fragment .replace(/^]*>/i, '') .replace(/<\/sql>\s*$/i, '') } function fragmentKeys(context: MapperFileContext, id: string) { return [ `${context.namespace}.${id}`, `${context.mapperFile}::${id}`, id, ].filter(Boolean) } function resolveIncludes(xml: string, context: MapperFileContext, fragments: Map, depth = 0): string { if (depth > 8) return xml return xml.replace(includePattern, (_include, refid: string) => { const candidates = refid.includes('.') ? [refid] : fragmentKeys(context, refid) const fragment = candidates.map((key) => fragments.get(key)).find(Boolean) if (!fragment) return _include return resolveIncludes(stripSqlTag(fragment), context, fragments, depth + 1) }) } async function readMapperContext(root: string, file: string): Promise { const xml = await fs.readFile(file, 'utf-8') const mapperFile = path.relative(root, file).replaceAll(path.sep, '/') const namespace = xml.match(mapperNamespacePattern)?.[1] ?? '' return { file, mapperFile, namespace, xml } } function collectFragments(context: MapperFileContext, fragments: Map) { for (const match of context.xml.matchAll(sqlFragmentPattern)) { const id = match[1] for (const key of fragmentKeys(context, id)) { fragments.set(key, match[0]) } } } function readMapperItems(context: MapperFileContext, fragments: Map): MapperSqlItem[] { const items: MapperSqlItem[] = [] for (const match of context.xml.matchAll(sqlTagPattern)) { const xmlText = resolveIncludes(match[0], context, fragments) items.push({ mapperFile: context.mapperFile, sqlId: match[2], sqlCommandType: match[1].toUpperCase(), xmlText, mapperXml: context.xml, }) } return items } export async function GET() { try { const settings = await loadSettings() const root = await resolveMapperRoot(settings.mapperRoot) const files = await collectXmlFiles(root) const contexts = await Promise.all(files.map((file) => readMapperContext(root, file))) const fragments = new Map() contexts.forEach((context) => collectFragments(context, fragments)) return NextResponse.json(contexts.flatMap((context) => readMapperItems(context, fragments))) } catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : 'Mapper XML 스캔 실패', }, { status: 400 }) } } ------------------------------ ------------------------------ app\api\scenario-file\route.ts ------------------------------ import { NextResponse } from 'next/server' import fs from 'fs/promises' import path from 'path' import type { ScenarioDefinition } from '../../lib/types' const dataDir = path.join(process.cwd(), 'data') const scenariosPath = path.join(dataDir, 'verification-scenarios.json') const metaPath = path.join(dataDir, 'verification-scenarios-meta.json') const defaultFileName = 'verification-scenarios.json' type ScenarioFileMeta = { fileName: string loadedAt?: string } async function ensureScenariosFile() { await fs.mkdir(dataDir, { recursive: true }) try { await fs.access(scenariosPath) } catch { const fallbackPath = path.join(process.cwd(), '..', 'target-spring-oracle', 'src', 'main', 'resources', 'verification-scenarios.json') try { await fs.copyFile(fallbackPath, scenariosPath) } catch { await fs.writeFile(scenariosPath, '[]\n', 'utf8') } } } async function readMeta(): Promise { try { const text = await fs.readFile(metaPath, 'utf8') const meta = JSON.parse(text) as Partial return { fileName: meta.fileName || defaultFileName, loadedAt: meta.loadedAt } } catch { return { fileName: defaultFileName } } } async function writeMeta(meta: ScenarioFileMeta) { await fs.mkdir(dataDir, { recursive: true }) await fs.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf8') } function normalizeScenarios(value: unknown): ScenarioDefinition[] { if (!Array.isArray(value)) { throw new Error('검증시나리오 JSON은 배열이어야 합니다.') } return value as ScenarioDefinition[] } export async function GET() { try { await ensureScenariosFile() const [text, meta] = await Promise.all([ fs.readFile(scenariosPath, 'utf8'), readMeta(), ]) return NextResponse.json({ ...meta, scenarios: normalizeScenarios(JSON.parse(text)), }) } catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : '시나리오 JSON 로드 실패' }, { status: 400 }) } } export async function POST(request: Request) { try { const body = await request.json() as { fileName?: string, scenarios?: unknown } const scenarios = normalizeScenarios(body.scenarios) const fileName = body.fileName?.trim() || defaultFileName await ensureScenariosFile() await fs.writeFile(scenariosPath, `${JSON.stringify(scenarios, null, 2)}\n`, 'utf8') const meta = { fileName, loadedAt: new Date().toISOString() } await writeMeta(meta) return NextResponse.json({ ...meta, scenarios }) } catch (error) { return NextResponse.json({ message: error instanceof Error ? error.message : '시나리오 JSON 저장 실패' }, { status: 400 }) } } ------------------------------ ------------------------------ app\api\scenarios\route.ts ------------------------------ import { NextRequest, NextResponse } from 'next/server' import fs from 'fs/promises' import path from 'path' const scenariosPath = path.join(process.cwd(), 'data', 'verification-scenarios.json') async function ensureScenariosFile() { await fs.mkdir(path.dirname(scenariosPath), { recursive: true }) try { await fs.access(scenariosPath) } catch { const fallbackPath = path.join(process.cwd(), '..', 'target-spring-oracle', 'src', 'main', 'resources', 'verification-scenarios.json') try { await fs.copyFile(fallbackPath, scenariosPath) } catch { await fs.writeFile(scenariosPath, '[]\n', 'utf8') } } } export async function GET() { await ensureScenariosFile() const text = await fs.readFile(scenariosPath, 'utf8') return NextResponse.json(JSON.parse(text)) } export async function POST(request: NextRequest) { const scenarios = await request.json() if (!Array.isArray(scenarios)) { return NextResponse.json({ message: '검증 시나리오 JSON은 배열이어야 합니다.' }, { status: 400 }) } await ensureScenariosFile() await fs.writeFile(scenariosPath, `${JSON.stringify(scenarios, null, 2)}\n`, 'utf8') return NextResponse.json(scenarios) } ------------------------------ ------------------------------ app\api\scenario-ui-state\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { ScenarioUiState } from '../../lib/types' const statePath = path.join(process.cwd(), 'data', 'scenario-ui-state.json') const emptyState: ScenarioUiState = { collapsedMapperFiles: [], collapsedSqlIds: [], verificationHistory: {}, cleanupInsertedRows: true, cleanupInsertedRowsByScenario: {}, } async function ensureFile() { await fs.mkdir(path.dirname(statePath), { recursive: true }) try { await fs.access(statePath) } catch { await fs.writeFile(statePath, `${JSON.stringify(emptyState, null, 2)}\n`, 'utf-8') } } function normalizeState(value: Partial | null | undefined): ScenarioUiState { return { collapsedMapperFiles: Array.isArray(value?.collapsedMapperFiles) ? value.collapsedMapperFiles : [], collapsedSqlIds: Array.isArray(value?.collapsedSqlIds) ? value.collapsedSqlIds : [], verificationHistory: value?.verificationHistory && typeof value.verificationHistory === 'object' ? value.verificationHistory : {}, cleanupInsertedRows: typeof value?.cleanupInsertedRows === 'boolean' ? value.cleanupInsertedRows : true, cleanupInsertedRowsByScenario: value?.cleanupInsertedRowsByScenario && typeof value.cleanupInsertedRowsByScenario === 'object' ? value.cleanupInsertedRowsByScenario : {}, } } export async function GET() { await ensureFile() const text = await fs.readFile(statePath, 'utf-8') return NextResponse.json(normalizeState(JSON.parse(text))) } export async function POST(request: Request) { const body = normalizeState(await request.json() as Partial) await ensureFile() await fs.writeFile(statePath, `${JSON.stringify(body, null, 2)}\n`, 'utf-8') return NextResponse.json(body) } ------------------------------ ------------------------------ app\api\settings\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { VerificationSettings } from '../../lib/types' const settingsPath = path.join(process.cwd(), 'data', 'verification-settings.json') const defaultSettings: VerificationSettings = { mapperRoot: '../target-spring-oracle/src/main/resources/mapper/business', } async function ensureFile() { await fs.mkdir(path.dirname(settingsPath), { recursive: true }) try { await fs.access(settingsPath) } catch { await fs.writeFile(settingsPath, JSON.stringify(defaultSettings, null, 2), 'utf-8') } } export async function GET() { await ensureFile() const text = await fs.readFile(settingsPath, 'utf-8') return NextResponse.json(JSON.parse(text) as VerificationSettings) } export async function POST(request: Request) { const settings = await request.json() as VerificationSettings if (!settings.mapperRoot?.trim()) { return NextResponse.json({ message: 'Mapper XML 루트 경로가 비어 있습니다.' }, { status: 400 }) } await ensureFile() const nextSettings = { mapperRoot: settings.mapperRoot.trim() } await fs.writeFile(settingsPath, JSON.stringify(nextSettings, null, 2), 'utf-8') return NextResponse.json(nextSettings) } ------------------------------ ------------------------------ app\api\spring\[...path]\route.ts ------------------------------ import { NextResponse } from 'next/server' const springBaseUrl = process.env.SPRING_BASE_URL ?? 'http://127.0.0.1:8080' type RouteContext = { params: Promise<{ path: string[] }> } async function proxy(request: Request, context: RouteContext) { const { path } = await context.params const targetUrl = new URL(`/${path.join('/')}`, springBaseUrl) const sourceUrl = new URL(request.url) targetUrl.search = sourceUrl.search const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.text() try { const response = await fetch(targetUrl, { method: request.method, headers: { 'Content-Type': request.headers.get('Content-Type') ?? 'application/json', }, body, cache: 'no-store', }) const text = await response.text() return new NextResponse(text, { status: response.status, headers: { 'Content-Type': response.headers.get('Content-Type') ?? 'application/json; charset=utf-8', }, }) } catch (error) { return NextResponse.json({ message: 'Spring 백엔드에 연결할 수 없습니다.', targetUrl: targetUrl.toString(), cause: error instanceof Error ? error.message : String(error), }, { status: 502 }) } } export async function GET(request: Request, context: RouteContext) { return proxy(request, context) } export async function POST(request: Request, context: RouteContext) { return proxy(request, context) } export async function PUT(request: Request, context: RouteContext) { return proxy(request, context) } export async function DELETE(request: Request, context: RouteContext) { return proxy(request, context) } ------------------------------ ------------------------------ app\api\targets\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' import type { CryptoTarget } from '../../lib/types' const targetPath = path.join(process.cwd(), 'data', 'crypto-targets.json') function normalizeTarget(target: Partial): CryptoTarget { return { tableName: (target.tableName ?? '').trim().toUpperCase(), plainColumn: (target.plainColumn ?? '').trim().toUpperCase(), cryptoType: (target.cryptoType ?? '').trim(), encColumn: (target.encColumn ?? '').trim().toUpperCase(), hashColumn: (target.hashColumn ?? '').trim().toUpperCase(), javaProperty: target.javaProperty ?? '', encryptTypeHandler: target.encryptTypeHandler ?? '', hashTypeHandler: target.hashTypeHandler ?? '', } } function uniqueTargets(targets: Array>) { const keyed = new Map() const keyless: CryptoTarget[] = [] for (const item of targets) { const target = normalizeTarget(item) const key = target.tableName || target.plainColumn ? `${target.tableName}.${target.plainColumn}` : '' if (!key) { if (target.cryptoType || target.encColumn || target.hashColumn || target.javaProperty || target.encryptTypeHandler || target.hashTypeHandler) { keyless.push(target) } continue } keyed.set(key, target) } return [...keyed.values(), ...keyless] } async function ensureFile() { await fs.mkdir(path.dirname(targetPath), { recursive: true }) try { await fs.access(targetPath) } catch { await fs.writeFile(targetPath, '[]', 'utf-8') } } export async function GET() { await ensureFile() const text = await fs.readFile(targetPath, 'utf-8') return NextResponse.json(uniqueTargets(JSON.parse(text))) } export async function POST(request: Request) { const targets = await request.json() as CryptoTarget[] if (!Array.isArray(targets)) { return NextResponse.json({ message: '암호화 대상 JSON은 배열이어야 합니다.' }, { status: 400 }) } await ensureFile() const unique = uniqueTargets(targets) await fs.writeFile(targetPath, JSON.stringify(unique, null, 2), 'utf-8') return NextResponse.json(unique) } ------------------------------ ------------------------------ app\api\tree-state\route.ts ------------------------------ import { promises as fs } from 'fs' import path from 'path' import { NextResponse } from 'next/server' const statePath = path.join(process.cwd(), 'data', 'tree-state.json') async function ensureFile() { await fs.mkdir(path.dirname(statePath), { recursive: true }) try { await fs.access(statePath) } catch { await fs.writeFile(statePath, JSON.stringify({ expandedIds: ['root'] }, null, 2), 'utf-8') } } export async function GET() { await ensureFile() const text = await fs.readFile(statePath, 'utf-8') return NextResponse.json(JSON.parse(text)) } export async function POST(request: Request) { const body = await request.json() as { expandedIds?: string[] } const expandedIds = Array.isArray(body.expandedIds) ? body.expandedIds : [] await ensureFile() await fs.writeFile(statePath, JSON.stringify({ expandedIds }, null, 2), 'utf-8') return NextResponse.json({ expandedIds }) } ------------------------------ ------------------------------ app\components\SqlViewer.tsx ------------------------------ 'use client' import { Fragment, useMemo, useState } from 'react' type InsertSql = { queryIdComment?: string table: string columns: string[] values: string[] } function splitTopLevelCsv(text: string) { const items: string[] = [] let current = '' let quote = false let depth = 0 for (let i = 0; i < text.length; i += 1) { const char = text[i] const next = text[i + 1] if (char === "'") { current += char if (quote && next === "'") { current += next i += 1 } else { quote = !quote } continue } if (!quote && char === '(') depth += 1 if (!quote && char === ')') depth = Math.max(0, depth - 1) if (!quote && depth === 0 && char === ',') { items.push(current.trim()) current = '' continue } current += char } if (current.trim()) items.push(current.trim()) return items } function parseInsertSql(sql: string): InsertSql | null { const trimmed = sql.trim() const commentMatch = trimmed.match(/^(\/\*\s*VERIFY_QUERY_ID:[\s\S]*?\*\/)\s*/i) const queryIdComment = commentMatch?.[1] const body = queryIdComment ? trimmed.slice(commentMatch[0].length).trim() : trimmed const match = body.match(/^INSERT\s+INTO\s+([^\s(]+)\s*\(([\s\S]*?)\)\s*VALUES\s*\(([\s\S]*?)\)\s*;?$/i) if (!match) return null return { queryIdComment, table: match[1], columns: splitTopLevelCsv(match[2]), values: splitTopLevelCsv(match[3]), } } function formatGenericSql(sql: string) { return sql .replace(/\s+/g, ' ') .replace(/\b(SELECT|FROM|WHERE|GROUP BY|ORDER BY|HAVING|VALUES|SET)\b/gi, '\n$1') .replace(/\b(INSERT INTO|UPDATE|DELETE FROM)\b/gi, '$1') .replace(/\b(AND|OR)\b/gi, '\n $1') .trim() } export default function SqlViewer({ sql }: { sql: string }) { const [activeIndex, setActiveIndex] = useState(null) const parsedInsert = useMemo(() => parseInsertSql(sql), [sql]) if (!parsedInsert) { return
{formatGenericSql(sql)}
} const pairCount = Math.max(parsedInsert.columns.length, parsedInsert.values.length) return (
{parsedInsert.queryIdComment &&
{parsedInsert.queryIdComment}
}
INSERT INTO {parsedInsert.table} (
{Array.from({ length: pairCount }).map((_, index) => ( setActiveIndex(index)} onBlur={() => setActiveIndex(null)} onMouseEnter={() => setActiveIndex(index)} onMouseLeave={() => setActiveIndex(null)} > {parsedInsert.columns[index] ?? ''} {index < pairCount - 1 ? ',' : ''} ))}
) VALUES (
{Array.from({ length: pairCount }).map((_, index) => ( setActiveIndex(index)} onBlur={() => setActiveIndex(null)} onMouseEnter={() => setActiveIndex(index)} onMouseLeave={() => setActiveIndex(null)} > {parsedInsert.values[index] ?? ''} {index < pairCount - 1 ? ',' : ''} ))}
)
) } ------------------------------ ------------------------------ app\components\VerificationTree.tsx ------------------------------ 'use client' import type { Status, VerificationNode } from '../lib/types' interface Props { nodes: VerificationNode[] selectedId?: string onSelect: (node: VerificationNode) => void expandedIds: string[] onToggle: (nodeId: string) => void onRun: (node: VerificationNode) => void onOpenMenu: (node: VerificationNode, x: number, y: number) => void } const statusLabel: Record = { READY: '대기', RUNNING: '진행중', PASS: '통과', FAIL: '실패', UNSUPPORTED: '지원불가', } export default function VerificationTree({ nodes, selectedId, onSelect, expandedIds, onToggle, onRun, onOpenMenu }: Props) { if (nodes.length === 0) { return (
검증 결과가 아직 없습니다. 상단의 전체 검증 실행을 누르면 Mapper 트리가 표시됩니다.
) } return (
    {nodes.map((node) => (
  • onSelect(node)}> {node.label} {node.type !== 'SCENARIO' && {node.progress ?? 0}%} {statusLabel[node.status] ?? node.status}
    {!!node.children?.length && expandedIds.includes(node.id) && ( )}
  • ))}
) } ------------------------------ ------------------------------ app\layout.tsx ------------------------------ import './styles.css' export const metadata = { title: '암복호화 검증 콘솔', description: 'Mapper TypeHandler 암복호화 검증 콘솔', } export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ------------------------------ ------------------------------ app\lib\spring-api.ts ------------------------------ import type { MapperSqlItem, RewriteCandidate, ScenarioDefinition, ScenarioFilePayload, ScenarioUiState, VerificationNode, VerificationSettings } from './types' const springBaseUrl = '/api/spring' async function callSpring(path: string, init?: RequestInit): Promise { const response = await fetch(`${springBaseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}), }, cache: 'no-store', }) if (!response.ok) { const text = await response.text() throw new Error(text || `Spring API 오류: ${response.status}`) } return response.json() as Promise } async function callLocal(path: string, init?: RequestInit): Promise { const response = await fetch(path, { ...init, headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}), }, cache: 'no-store', }) if (!response.ok) { const text = await response.text() throw new Error(text || `Next API 오류: ${response.status}`) } return response.json() as Promise } export function makeVerificationRunId() { return `VERIFY_${Date.now()}_${crypto.randomUUID()}` } export function runVerification(payload: Record) { return callSpring('/crypto-verify/run', { method: 'POST', body: JSON.stringify(payload), }) } export function runScenario(scenarioId: string, payload: Record) { return callSpring(`/crypto-verify/run/${encodeURIComponent(scenarioId)}`, { method: 'POST', body: JSON.stringify(payload), }) } export function loadScenarios() { return callLocal('/api/scenarios') } export function loadScenarioFile() { return callLocal('/api/scenario-file') } export function loadMapperSqlIds() { return callLocal('/api/mapper-sql-ids') } export function loadSettings() { return callLocal('/api/settings') } export function saveSettings(settings: VerificationSettings) { return callLocal('/api/settings', { method: 'POST', body: JSON.stringify(settings), }) } export function saveScenarios(scenarios: ScenarioDefinition[]) { return callLocal('/api/scenarios', { method: 'POST', body: JSON.stringify(scenarios), }) } export function saveScenarioFile(fileName: string, scenarios: ScenarioDefinition[]) { return callLocal('/api/scenario-file', { method: 'POST', body: JSON.stringify({ fileName, scenarios }), }) } export function loadScenarioUiState() { return callLocal('/api/scenario-ui-state') } export function saveScenarioUiState(state: ScenarioUiState) { return callLocal('/api/scenario-ui-state', { method: 'POST', body: JSON.stringify(state), }) } export function loadRewriteCandidates() { return callLocal('/api/mapper-rewrite/candidates') } export function previewRewrite(candidate: Pick) { return callLocal('/api/mapper-rewrite/preview', { method: 'POST', body: JSON.stringify(candidate), }) } export function applyRewrite(candidate: Pick & { afterSql?: string, supportSnippets?: string[] }) { return callLocal('/api/mapper-rewrite', { method: 'POST', body: JSON.stringify(candidate), }) } ------------------------------ ------------------------------ app\lib\types.ts ------------------------------ export type Status = 'READY' | 'RUNNING' | 'PASS' | 'FAIL' | 'UNSUPPORTED' export interface VerificationNode { id: string label: string type: string status: Status progress: number mapperName?: string sqlId?: string targetColumn?: string typeHandlerName?: string inputPlainText?: string dbCipherText?: string decryptedText?: string exceptionMessage?: string elapsedMs?: number setParameterCount?: number getResultCount?: number details?: Record children?: VerificationNode[] } export interface CryptoTarget { tableName: string plainColumn: string cryptoType: string encColumn: string hashColumn: string javaProperty: string encryptTypeHandler: string hashTypeHandler: string } export interface CryptoTypeHandlerMapping { cryptoType: string encryptTypeHandler: string hashTypeHandler: string } export interface ScenarioDefinition { scenarioId: string displayName: string mapperFile: string sqlId: string sqlCommandType: string scenarioType: string targetTable: string targetColumn: string encColumn: string hashColumn: string javaProperty: string encryptTypeHandler: string hashTypeHandler: string executionMode?: string defaultParameters?: Record operations?: Array> verification?: Record | null } export interface ScenarioFilePayload { fileName: string loadedAt?: string scenarios: ScenarioDefinition[] } export interface ScenarioVerificationHistory { status: Status progress: number updatedAt: string label?: string elapsedMs?: number exceptionMessage?: string details?: Record } export interface ScenarioUiState { collapsedMapperFiles: string[] collapsedSqlIds: string[] verificationHistory: Record cleanupInsertedRows?: boolean cleanupInsertedRowsByScenario?: Record } export interface MapperSqlItem { mapperFile: string sqlId: string sqlCommandType: string xmlText?: string mapperXml?: string } export interface VerificationSettings { mapperRoot: string } export interface RewriteCandidate { mapperFile: string sqlId: string rewriteType: string riskLevel: string beforeSql: string afterSql: string diff: string warnings: string[] supportSnippets?: string[] } ------------------------------ ------------------------------ app\page.tsx ------------------------------ 'use client' import { Fragment, type MouseEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import SqlViewer from './components/SqlViewer' import VerificationTree from './components/VerificationTree' import { applyRewrite, loadMapperSqlIds, loadRewriteCandidates, loadScenarioFile, loadScenarioUiState, loadSettings, makeVerificationRunId, previewRewrite, runScenario, runVerification, saveScenarioFile, saveScenarioUiState, saveSettings, } from './lib/spring-api' import type { CryptoTarget, CryptoTypeHandlerMapping, MapperSqlItem, RewriteCandidate, ScenarioDefinition, ScenarioUiState, ScenarioVerificationHistory, Status, VerificationNode } from './lib/types' type Tab = 'verify' | 'targets' | 'scenarios' | 'settings' type ScenarioViewMode = 'json' | 'grid' type ColumnKind = 'encrypt' | 'hash' type ColumnAction = 'add' | 'drop' const targetMenuWidth = 220 const viewportMenuMargin = 12 const queryLogStorageKey = 'crypto-verify-query-logs' const queryLogChannelName = 'crypto-verify-query-log-channel' interface OptionGuideState { title: string description: string steps: string[] checks?: string[] } interface ContextMenuState { node: VerificationNode x: number y: number } interface TargetMenuState { x: number y: number } type QueryLogEntry = { scenarioId: string scenarioLabel: string queryKey: string queryId?: string mode?: string command?: string statement?: string fullSql: string parameter?: unknown updatedAt: string } type ScenarioSeed = Partial & { xmlText?: string } const emptyScenarioUiState: ScenarioUiState = { collapsedMapperFiles: [], collapsedSqlIds: [], verificationHistory: {}, cleanupInsertedRows: true, cleanupInsertedRowsByScenario: {}, } type ColumnLengthLookup = { loading?: boolean message?: string target?: string enc?: string hash?: string } type RandomColumnSample = { loading?: boolean message?: string values?: string[] } type PrimaryKeyColumn = { columnName: string dataType: string dataLength?: number charLength?: number | null charUsed?: string | null position?: number } function emptyTarget(): CryptoTarget { return { tableName: '', plainColumn: '', cryptoType: '', encColumn: '', hashColumn: '', javaProperty: '', encryptTypeHandler: '', hashTypeHandler: '', } } function emptyCryptoTypeHandlerMapping(): CryptoTypeHandlerMapping { return { cryptoType: '', encryptTypeHandler: '', hashTypeHandler: '', } } function normalizeCryptoTarget(target: Partial): CryptoTarget { return { ...emptyTarget(), ...target, tableName: (target.tableName ?? '').trim().toUpperCase(), plainColumn: (target.plainColumn ?? '').trim().toUpperCase(), cryptoType: (target.cryptoType ?? '').trim(), encColumn: (target.encColumn ?? '').trim().toUpperCase(), hashColumn: (target.hashColumn ?? '').trim().toUpperCase(), javaProperty: target.javaProperty ?? '', encryptTypeHandler: target.encryptTypeHandler ?? '', hashTypeHandler: target.hashTypeHandler ?? '', } } function normalizeCryptoTypeHandlerMapping(mapping: Partial): CryptoTypeHandlerMapping { return { cryptoType: (mapping.cryptoType ?? '').trim(), encryptTypeHandler: (mapping.encryptTypeHandler ?? '').trim(), hashTypeHandler: (mapping.hashTypeHandler ?? '').trim(), } } function cryptoTargetKey(target: CryptoTarget) { const tableName = target.tableName.trim().toUpperCase() const plainColumn = target.plainColumn.trim().toUpperCase() if (!tableName && !plainColumn) return '' return `${tableName}.${plainColumn}` } function uniqueCryptoTargets(targets: Array>) { const keyed = new Map() const keyless: CryptoTarget[] = [] for (const item of targets) { const target = normalizeCryptoTarget(item) const key = cryptoTargetKey(target) if (!key) { if (target.tableName || target.plainColumn || target.cryptoType || target.encColumn || target.hashColumn || target.javaProperty || target.encryptTypeHandler || target.hashTypeHandler) { keyless.push(target) } continue } keyed.set(key, target) } return [...keyed.values(), ...keyless] } function mergeCryptoTargets(currentTargets: CryptoTarget[], importedTargets: CryptoTarget[]) { return uniqueCryptoTargets([...currentTargets, ...importedTargets]) } function cryptoTypeHandlerMappingKey(mapping: CryptoTypeHandlerMapping) { return mapping.cryptoType.trim().toUpperCase() } function uniqueCryptoTypeHandlerMappings(mappings: Array>) { const keyed = new Map() const keyless: CryptoTypeHandlerMapping[] = [] for (const item of mappings) { const mapping = normalizeCryptoTypeHandlerMapping(item) const key = cryptoTypeHandlerMappingKey(mapping) if (!key) { if (mapping.encryptTypeHandler || mapping.hashTypeHandler) keyless.push(mapping) continue } keyed.set(key, mapping) } return [...keyed.values(), ...keyless] } function cryptoTypeMappingFor(mappings: CryptoTypeHandlerMapping[], cryptoType: string) { const normalized = cryptoType.trim().toUpperCase() if (!normalized) return undefined return mappings.find((mapping) => mapping.cryptoType.trim().toUpperCase() === normalized) } function applyCryptoTypeHandlersToTarget(target: CryptoTarget, mappings: CryptoTypeHandlerMapping[]): CryptoTarget { const mapping = cryptoTypeMappingFor(mappings, target.cryptoType) if (!mapping) return target return { ...target, encryptTypeHandler: mapping.encryptTypeHandler || target.encryptTypeHandler, hashTypeHandler: target.hashColumn ? mapping.hashTypeHandler || target.hashTypeHandler : target.hashTypeHandler, } } function toCamel(column: string) { return column .toLowerCase() .replace(/_([a-z])/g, (_, char: string) => char.toUpperCase()) } function sanitizeId(value: string) { return value.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase() || 'ROW' } function inferMaxLength(column: string) { const normalized = column.toUpperCase() if (normalized.includes('ADDRESS')) return 500 if (normalized.includes('PHONE')) return 40 if (normalized.includes('NAME')) return 300 return 100 } function inferEncryptTypeHandler(column: string) { const normalized = column.toUpperCase() if (normalized.includes('PHONE')) return 'PhoneEncryptTypeHandler' if (normalized.includes('ADDRESS')) return 'AddressEncryptTypeHandler' if (normalized.includes('NAME') || normalized.includes('BUYER') || normalized.includes('CUSTOMER')) return 'NameEncryptTypeHandler' return '' } function inferHashTypeHandler(column: string, encryptTypeHandler?: string) { if (encryptTypeHandler?.includes('EncryptTypeHandler')) { return encryptTypeHandler.replace('EncryptTypeHandler', 'HashTypeHandler') } const normalized = column.toUpperCase() if (normalized.includes('PHONE')) return 'PhoneHashTypeHandler' if (normalized.includes('ADDRESS')) return 'AddressHashTypeHandler' if (normalized.includes('NAME') || normalized.includes('BUYER') || normalized.includes('CUSTOMER')) return 'NameHashTypeHandler' return '' } function targetFromTableColumn(tableName: string, plainColumn: string, cryptoType = '', useHash = false): CryptoTarget { const table = tableName.trim().toUpperCase() const column = plainColumn.trim().toUpperCase() const encryptTypeHandler = inferEncryptTypeHandler(column) return { tableName: table, plainColumn: column, cryptoType: cryptoType.trim(), encColumn: column ? `${column}_ENC` : '', hashColumn: useHash && column ? `${column}_HASH` : '', javaProperty: toCamel(column), encryptTypeHandler, hashTypeHandler: useHash ? inferHashTypeHandler(column, encryptTypeHandler) : '', } } function parseCryptoTargetsText(text: string): CryptoTarget[] { const trimmed = text.trim() if (!trimmed) return [] if (trimmed.startsWith('[')) return (JSON.parse(trimmed) as Array>).map(normalizeCryptoTarget) const isHeaderLine = (line: string) => { const [tableName = '', columnName = '', cryptoType = ''] = line.split('|').map((part) => part.trim().toUpperCase()) const tableHeader = ['TABLE', 'TABLE_NAME', 'TABLENAME', '테이블'].includes(tableName) const columnHeader = ['COLUMN', 'COLUMN_NAME', 'COLUMNNAME', 'COL', 'COLNAME', '컬럼', '컬럼명'].includes(columnName) const cryptoTypeHeader = !cryptoType || ['CRYPTOTYPE', 'CRYPTO_TYPE', '암호화타입', '암호화 타입'].includes(cryptoType) return tableHeader && columnHeader && cryptoTypeHeader } return trimmed .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#') && !isHeaderLine(line)) .map((line) => { const [tableName = '', plainColumn = '', third = '', fourth = ''] = line.split('|').map((part) => part.trim()) if (!tableName || !plainColumn) { throw new Error(`TXT 형식 오류: ${line}`) } const thirdIsHashFlag = /^Y(?:ES)?$/i.test(third) const cryptoType = thirdIsHashFlag ? '' : third const useHash = thirdIsHashFlag || /^Y(?:ES)?$/i.test(fourth) return targetFromTableColumn(tableName, plainColumn, cryptoType, useHash) }) } function inferScenarioType(sqlCommandType: string | undefined, xmlText?: string) { const command = (sqlCommandType || '').toUpperCase() const xml = xmlText || '' if (command === 'INSERT' || command === 'UPDATE') return 'MAX_LENGTH_INSERT_UPDATE' if (command === 'SELECT' && /\bJOIN\b/i.test(xml)) return 'JOIN_HASH_COLUMN_MATCH' if (command === 'SELECT' && /_HASH\b/i.test(xml)) return 'HASH_WHERE_EXACT_MATCH' if (command === 'SELECT' && /_ENC\s+AS\s+/i.test(xml)) return 'SELECT_ENC_ALIAS_DECRYPT_MAPPING' return 'MAX_LENGTH_INSERT_UPDATE' } function inferTargetFromXml(xmlText: string | undefined, targets: CryptoTarget[]) { if (!xmlText) return undefined const xml = xmlText.toUpperCase() return targets .map((target) => { const tokens = [ target.tableName, target.plainColumn, target.encColumn, target.hashColumn, target.encryptTypeHandler, target.hashTypeHandler, ].filter(Boolean) const score = tokens.reduce((sum, token) => sum + (xml.includes(token.toUpperCase()) ? 1 : 0), 0) return { target, score } }) .filter((item) => item.score > 0) .sort((left, right) => right.score - left.score)[0]?.target } function hasEncryptHint(xmlText?: string) { return !!xmlText && /(typeHandler\s*=|_ENC\b|_HASH\b)/i.test(xmlText) } function mapperBase(mapperFile?: string) { const fileName = (mapperFile || 'UserMapper.xml').split(/[\\/]/).pop() || 'UserMapper.xml' return fileName.replace(/Mapper\.xml$/i, '') } function mapperNamespace(mapperFile?: string) { const base = mapperBase(mapperFile) return `com.samsung.cryptverify.business.${base.toLowerCase()}.${base}Mapper` } function dtoType(mapperFile?: string) { const base = mapperBase(mapperFile) return `com.samsung.cryptverify.business.${base.toLowerCase()}.${base}Dto` } function idColumnFor(tableName?: string) { if (tableName === 'TB_CUSTOMER') return 'CUSTOMER_ID' if (tableName === 'TB_ORDER') return 'ORDER_ID' return 'USER_ID' } function idPropertyFor(tableName?: string) { if (tableName === 'TB_CUSTOMER') return 'customerId' if (tableName === 'TB_ORDER') return 'orderId' return 'userId' } function defaultInsertSqlId(scenario: Pick) { if (scenario.sqlCommandType === 'INSERT' && scenario.sqlId) return scenario.sqlId if (scenario.targetTable === 'TB_CUSTOMER') return 'insertCustomer' if (scenario.targetTable === 'TB_ORDER') return 'insertOrder' return 'insertUser' } function defaultUpdateSqlId(targetTable?: string) { if (targetTable === 'TB_CUSTOMER') return 'updateCustomer' if (targetTable === 'TB_USER') return 'updateUserDynamic' return '' } function defaultSelectSqlId(targetTable?: string) { if (targetTable === 'TB_CUSTOMER') return 'selectCustomerByNameHash' if (targetTable === 'TB_ORDER') return 'selectOrdersByBuyerJoin' return 'selectUserByNameHash' } function defaultSelectStatementSqlId(scenario: Pick) { if (scenario.sqlCommandType === 'SELECT' && scenario.sqlId) return scenario.sqlId return defaultSelectSqlId(scenario.targetTable) } function dtoParameterForScenario(scenario: ScenarioDefinition, plainExpression: string) { const idProperty = idPropertyFor(scenario.targetTable) const parameter: Record = { [idProperty]: `\${${idProperty}}`, [scenario.javaProperty || 'plain']: plainExpression, verificationRunId: '${verificationRunId}', } if (scenario.targetTable === 'TB_USER') { parameter.phone = '${phone}' parameter.email = '${email}' } else if (scenario.targetTable === 'TB_CUSTOMER') { parameter.address = '${address}' parameter.gradeCode = '${gradeCode}' } else if (scenario.targetTable === 'TB_ORDER') { parameter.userId = '${userId}' parameter.amount = '${amount}' } return parameter } function rawOperationForScenario(scenario: ScenarioDefinition, name: string) { const idColumn = idColumnFor(scenario.targetTable) const idProperty = idPropertyFor(scenario.targetTable) return { name, mode: 'DYNAMIC_SQL', command: 'SELECT_ONE', table: scenario.targetTable, columns: [idColumn, scenario.targetColumn, scenario.encColumn, scenario.hashColumn, 'VERIFICATION_RUN_ID'].filter(Boolean), where: { [idColumn]: idProperty }, parameter: { [idProperty]: `\${${idProperty}}` }, resultKey: name, } } function primaryKeyColumnsForScenario(scenario: ScenarioDefinition): PrimaryKeyColumn[] { const value = scenario.defaultParameters?.__primaryKeyColumns return Array.isArray(value) ? value as PrimaryKeyColumn[] : [] } function pkPropertyForColumn(columnName: string) { return toCamel(columnName) } function randomPkTemplate(column: PrimaryKeyColumn) { const dataType = column.dataType.toUpperCase() if (/(NUMBER|INTEGER|DECIMAL|NUMERIC|FLOAT|DOUBLE)/.test(dataType)) { return `${Date.now() % 100000000}\${index}` } const maxLength = Number(column.charLength ?? column.dataLength ?? 40) const prefix = `CV${Date.now().toString(36).toUpperCase()}` if (maxLength <= 12) return `${prefix.slice(0, Math.max(2, maxLength - 3))}\${index}` return `${prefix}_\${index}` } function cleanupOperationForScenario(scenario: ScenarioDefinition) { const pkColumns = primaryKeyColumnsForScenario(scenario) if (pkColumns.length === 0) return null const where: Record = {} const parameter: Record = {} pkColumns.forEach((column) => { const property = pkPropertyForColumn(column.columnName) where[column.columnName] = property parameter[property] = `\${${property}}` }) return { name: 'cleanupByPk', mode: 'DYNAMIC_SQL', command: 'DELETE', table: scenario.targetTable, where, parameter, cleanup: true, resultKey: 'cleanupCount', } } function defaultOperationsForScenario(scenario: ScenarioDefinition): Array> { if (scenario.scenarioType === 'TYPEHANDLER_MISSING_DETECTION') return [] const namespace = mapperNamespace(scenario.mapperFile) const operations: Array> = [ { name: 'insert', mode: 'SQL_SESSION', command: 'INSERT', statement: `${namespace}.${defaultInsertSqlId(scenario)}`, parameterType: dtoType(scenario.mapperFile), parameter: dtoParameterForScenario(scenario, scenario.scenarioType === 'MAX_LENGTH_INSERT_UPDATE' ? `\${${scenario.javaProperty}}` : `\${${scenario.javaProperty}}`), resultKey: 'insertCount', }, rawOperationForScenario(scenario, 'raw'), ] const updateSqlId = scenario.scenarioType === 'MAX_LENGTH_INSERT_UPDATE' ? (scenario.sqlCommandType === 'UPDATE' && scenario.sqlId ? scenario.sqlId : defaultUpdateSqlId(scenario.targetTable)) : '' if (updateSqlId) { operations.push({ name: 'update', mode: 'SQL_SESSION', command: 'UPDATE', statement: `${namespace}.${updateSqlId}`, parameterType: dtoType(scenario.mapperFile), parameter: dtoParameterForScenario(scenario, '${updatedPlain}'), resultKey: 'updateCount', }) operations.push(rawOperationForScenario(scenario, 'rawAfterUpdate')) } operations.push({ name: 'decrypted', mode: 'SQL_SESSION', command: 'SELECT_FIRST', statement: `${namespace}.${defaultSelectStatementSqlId(scenario)}`, parameter: { [scenario.javaProperty || 'plain']: updateSqlId ? '${updatedPlain}' : `\${${scenario.javaProperty}}` }, resultKey: 'decrypted', }) const cleanup = cleanupOperationForScenario(scenario) if (cleanup) { operations.push(cleanup) } return operations } function defaultVerificationForScenario(scenario: ScenarioDefinition): Record | null { if (scenario.scenarioType === 'TYPEHANDLER_MISSING_DETECTION') return null const hasUpdate = scenario.scenarioType === 'MAX_LENGTH_INSERT_UPDATE' && !!defaultUpdateSqlId(scenario.targetTable) return { plain: hasUpdate ? '${updatedPlain}' : `\${${scenario.javaProperty}}`, rawResult: hasUpdate ? 'rawAfterUpdate' : 'raw', decryptedResult: 'decrypted', cipherColumn: scenario.encColumn, hashColumn: scenario.hashColumn, decryptedProperty: scenario.javaProperty, checks: [ 'CIPHER_NOT_PLAIN', 'HASH_NOT_PLAIN', 'DECRYPTED_EQUALS_PLAIN', 'ENCRYPT_TYPEHANDLER_SET_CALLED', 'HASH_TYPEHANDLER_SET_CALLED', 'ENCRYPT_TYPEHANDLER_GET_CALLED', ], } } function defaultParametersForScenario(scenario: Pick): Record { const idProperty = idPropertyFor(scenario.targetTable) return { [idProperty]: `${idProperty.toUpperCase()}_\${index}`, [scenario.javaProperty || toCamel(scenario.targetColumn || 'plain')]: `${toCamel(scenario.targetColumn || 'plain')}_verify_\${index}`, phone: '010-1234-0001', email: 'verify@example.com', address: 'address_verify_${index}', gradeCode: 'A', amount: 2000, verificationRunId: '${testRunId}', maxLength: inferMaxLength(scenario.targetColumn || ''), } } function hasOperations(value: unknown): value is Array> { return Array.isArray(value) && value.length > 0 } function regenerateScenarioExecution(scenario: ScenarioDefinition): ScenarioDefinition { return { ...scenario, operations: defaultOperationsForScenario(scenario), verification: defaultVerificationForScenario(scenario), } } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function stripXmlNoise(xmlText?: string) { return (xmlText ?? '') .replace(//g, ' ') .replace(/]*>/gi, ' ') .replace(/<\/?(select|insert|update|delete)\b[^>]*>/gi, ' ') .replace(/\s+/g, ' ') } function tableAliases(sqlText: string, tableName: string) { const aliases = new Set([tableName.toUpperCase()]) const table = escapeRegExp(tableName) const patterns = [ new RegExp(`\\b(?:FROM|JOIN|INTO)\\s+${table}\\b(?:\\s+(?:AS\\s+)?([A-Z_][A-Z0-9_]*))?`, 'gi'), new RegExp(`\\bUPDATE\\s+${table}\\b(?:\\s+(?:AS\\s+)?([A-Z_][A-Z0-9_]*))?`, 'gi'), ] for (const pattern of patterns) { for (const match of sqlText.matchAll(pattern)) { const alias = match[1]?.toUpperCase() if (alias && !['SET', 'WHERE', 'VALUES', 'SELECT', 'ON'].includes(alias)) aliases.add(alias) } } return [...aliases] } function hasColumnReference(sqlText: string, columnName?: string, aliases: string[] = []) { if (!columnName) return false const column = escapeRegExp(columnName.toUpperCase()) if (new RegExp(`\\b${column}\\b`, 'i').test(sqlText)) return true return aliases.some((alias) => new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*${column}\\b`, 'i').test(sqlText)) } function hasColumnAssignment(sqlText: string, columnName?: string, aliases: string[] = []) { if (!columnName) return false const column = escapeRegExp(columnName.toUpperCase()) if (new RegExp(`\\b${column}\\b\\s*=`, 'i').test(sqlText)) return true return aliases.some((alias) => new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*${column}\\b\\s*=`, 'i').test(sqlText)) } function whereFragments(sqlText: string) { const fragments: string[] = [] const pattern = /\bWHERE\b([\s\S]*?)(?=\bGROUP\s+BY\b|\bORDER\s+BY\b|\bHAVING\b|\bUNION\b|<\/(?:select|update|delete)>|$)/gi for (const match of sqlText.matchAll(pattern)) { fragments.push(match[1]) } return fragments } function columnUsedInWhere(xmlText: string | undefined, tableName: string, columnName: string) { if (!xmlText || !tableName || !columnName) return false const sql = stripXmlNoise(xmlText).toUpperCase() if (!new RegExp(`\\b${escapeRegExp(tableName.toUpperCase())}\\b`, 'i').test(sql)) return false const aliases = tableAliases(sql, tableName) const column = escapeRegExp(columnName.toUpperCase()) const columnPattern = new RegExp(`\\b${column}\\b`, 'i') const aliasPatterns = aliases.map((alias) => new RegExp(`\\b${escapeRegExp(alias)}\\s*\\.\\s*${column}\\b`, 'i')) return whereFragments(sql).some((where) => columnPattern.test(where) || aliasPatterns.some((pattern) => pattern.test(where))) } function targetAppearsInMapper(item: MapperSqlItem, target: CryptoTarget) { const sql = stripXmlNoise(item.xmlText).toUpperCase() const aliases = tableAliases(sql, target.tableName) const tableMentioned = new RegExp(`\\b${escapeRegExp(target.tableName.toUpperCase())}\\b`, 'i').test(sql) const columnMentioned = [ target.plainColumn, target.encColumn, target.hashColumn, ].some((column) => hasColumnReference(sql, column, aliases)) return tableMentioned && columnMentioned } function scenarioTypesForMapperItem(item: MapperSqlItem, target: CryptoTarget) { const command = item.sqlCommandType.toUpperCase() const sql = stripXmlNoise(item.xmlText).toUpperCase() const aliases = tableAliases(sql, target.tableName) const hasHash = !!target.hashColumn?.trim() const types = new Set() if (command === 'INSERT') { types.add('INSERT_ROUND_TRIP') types.add('MAX_LENGTH_INSERT_UPDATE') } if (command === 'UPDATE') { types.add('MAX_LENGTH_INSERT_UPDATE') const updatesEncryptedTarget = hasColumnAssignment(sql, target.encColumn, aliases) || hasColumnAssignment(sql, target.plainColumn, aliases) if (hasHash && updatesEncryptedTarget) { types.add('TYPEHANDLER_MISSING_DETECTION') } } if (command === 'SELECT') { if (/\bJOIN\b/i.test(sql) && hasHash && hasColumnReference(sql, target.hashColumn, aliases)) { types.add('JOIN_HASH_COLUMN_MATCH') } if (hasHash && /\bWHERE\b/i.test(sql) && hasColumnReference(sql, target.hashColumn, aliases)) { types.add('HASH_WHERE_EXACT_MATCH') } if ( hasColumnReference(sql, target.encColumn, aliases) || new RegExp(`${escapeRegExp(target.encColumn.toUpperCase())}\\s+AS\\s+${escapeRegExp(target.plainColumn.toUpperCase())}`, 'i').test(sql) ) { types.add('SELECT_ENC_ALIAS_DECRYPT_MAPPING') } } return [...types] } function discoveredScenarioFromMapperItem(item: MapperSqlItem, target: CryptoTarget, scenarioType: string): ScenarioDefinition { const base: ScenarioDefinition = { scenarioId: `AUTO_${sanitizeId(item.mapperFile)}_${sanitizeId(item.sqlId)}_${sanitizeId(target.plainColumn)}_${sanitizeId(scenarioType)}`, displayName: `${item.sqlId} ${scenarioTypeLabel(scenarioType)}`, mapperFile: item.mapperFile, sqlId: item.sqlId, sqlCommandType: item.sqlCommandType, scenarioType, targetTable: target.tableName, targetColumn: target.plainColumn, encColumn: target.encColumn, hashColumn: target.hashColumn, javaProperty: target.javaProperty, encryptTypeHandler: target.encryptTypeHandler, hashTypeHandler: target.hashTypeHandler, executionMode: scenarioType === 'TYPEHANDLER_MISSING_DETECTION' ? undefined : 'SQL_SESSION', defaultParameters: {}, } base.defaultParameters = { ...defaultParametersForScenario(base), discoveredFrom: 'mapper-rescan', } return regenerateScenarioExecution(base) } function discoverScenariosFromMappers(mapperItems: MapperSqlItem[], targets: CryptoTarget[]) { const scenarios: ScenarioDefinition[] = [] const seen = new Set() for (const item of mapperItems) { if (!['INSERT', 'UPDATE', 'SELECT'].includes(item.sqlCommandType.toUpperCase())) continue for (const target of targets) { if (!target.tableName || !target.plainColumn || !target.encColumn) continue if (!targetAppearsInMapper(item, target)) continue for (const scenarioType of scenarioTypesForMapperItem(item, target)) { const scenario = discoveredScenarioFromMapperItem(item, target, scenarioType) if (seen.has(scenario.scenarioId)) continue seen.add(scenario.scenarioId) scenarios.push(scenario) } } } return scenarios } async function loadLocalTargets() { const response = await fetch('/api/targets', { cache: 'no-store' }) if (!response.ok) throw new Error(await response.text()) const items = await response.json() as Array> return uniqueCryptoTargets(items) } async function saveLocalTargets(targets: CryptoTarget[]) { const normalizedTargets = uniqueCryptoTargets(targets) const response = await fetch('/api/targets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(normalizedTargets), }) if (!response.ok) throw new Error(await response.text()) const items = await response.json() as Array> return uniqueCryptoTargets(items) } async function loadCryptoTypeHandlerMappings() { const response = await fetch('/api/crypto-type-handler-mappings', { cache: 'no-store' }) if (!response.ok) throw new Error(await response.text()) const items = await response.json() as Array> return uniqueCryptoTypeHandlerMappings(items) } async function saveCryptoTypeHandlerMappings(mappings: CryptoTypeHandlerMapping[]) { const normalizedMappings = uniqueCryptoTypeHandlerMappings(mappings) const response = await fetch('/api/crypto-type-handler-mappings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(normalizedMappings), }) if (!response.ok) throw new Error(await response.text()) const items = await response.json() as Array> return uniqueCryptoTypeHandlerMappings(items) } async function loadTreeState() { const response = await fetch('/api/tree-state', { cache: 'no-store' }) if (!response.ok) return { expandedIds: ['root'] } return response.json() as Promise<{ expandedIds: string[] }> } async function saveTreeState(expandedIds: string[]) { await fetch('/api/tree-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expandedIds }), }) } export default function Page() { const [tab, setTab] = useState('targets') const [running, setRunning] = useState(false) const [verificationRunId, setVerificationRunId] = useState('') const [plainValue, setPlainValue] = useState('') const [tree, setTree] = useState([]) const [selected, setSelected] = useState() const [expandedIds, setExpandedIds] = useState(['root']) const [contextMenu, setContextMenu] = useState() const [optionGuide, setOptionGuide] = useState() const [queryLogs, setQueryLogs] = useState([]) const [operationRunningKeys, setOperationRunningKeys] = useState>({}) const [operationQueryCaptures, setOperationQueryCaptures] = useState>({}) const [targets, setTargets] = useState([]) const [cryptoTypeHandlerMappings, setCryptoTypeHandlerMappings] = useState([]) const [targetMessage, setTargetMessage] = useState('') const [targetError, setTargetError] = useState('') const [targetLoadingMessage, setTargetLoadingMessage] = useState('') const [targetMenu, setTargetMenu] = useState() const [columnJob, setColumnJob] = useState<{ action: ColumnAction, kind: ColumnKind } | null>(null) const [targetSamples, setTargetSamples] = useState>({}) const randomSampleRequestedKeys = useRef>(new Set()) const [scenarioJson, setScenarioJson] = useState('') const [scenarioFileName, setScenarioFileName] = useState('verification-scenarios.json') const [scenarioViewMode, setScenarioViewMode] = useState('grid') const [scenarioMessage, setScenarioMessage] = useState('') const [scenarioRunning, setScenarioRunning] = useState(false) const [scenarioUiState, setScenarioUiState] = useState(emptyScenarioUiState) const [scenarioUiStateLoaded, setScenarioUiStateLoaded] = useState(false) const [mapperSqlItems, setMapperSqlItems] = useState([]) const [mapperRoot, setMapperRoot] = useState('') const [settingsMessage, setSettingsMessage] = useState('') const [settingsError, setSettingsError] = useState('') const [rewrites, setRewrites] = useState([]) const [selectedRewrite, setSelectedRewrite] = useState() const [rewriteAfterSql, setRewriteAfterSql] = useState('') const [error, setError] = useState('') const fileInputRef = useRef(null) const scenarioFileInputRef = useRef(null) const scenarios = useMemo(() => { try { const parsed = JSON.parse(scenarioJson) as ScenarioDefinition[] return Array.isArray(parsed) ? parsed : [] } catch { return [] } }, [scenarioJson]) const detailJson = useMemo(() => { if (!selected?.details) return '' return JSON.stringify(selected.details, null, 2) }, [selected]) const selectedScenario = useMemo(() => { if (!selected) return undefined return findScenarioForNode(selected, scenarios) }, [selected, scenarios]) function cleanupEnabledForScenario(scenarioId: string) { return scenarioUiState.cleanupInsertedRowsByScenario?.[scenarioId] ?? scenarioUiState.cleanupInsertedRows ?? true } function verificationOverrides(scenarioIds: string[] = scenarios.map((scenario) => scenario.scenarioId)) { return { verificationRunId, userName: plainValue, buyerName: plainValue, cleanupInsertedRows: scenarioUiState.cleanupInsertedRows ?? true, cleanupInsertedRowsByScenario: Object.fromEntries(scenarioIds.map((scenarioId) => [scenarioId, cleanupEnabledForScenario(scenarioId)])), } } function appendQueryLogsFromResult(result: VerificationNode) { const entries = collectQueryLogs(result) if (entries.length === 0) return setQueryLogs((current) => [...entries, ...current].slice(0, 200)) } function openQueryLogWindow() { window.localStorage.setItem(queryLogStorageKey, JSON.stringify(queryLogs)) window.open('/query-log', 'cryptoVerifyQueryLog', 'width=1180,height=820,menubar=no,toolbar=no,location=no,status=no') } function operationUiKey(scenarioId: string, operationIndex: number) { return `${scenarioId}:${operationIndex}` } async function runScenarioOperation(scenario: ScenarioDefinition, operation: Record, operationIndex: number) { const uiKey = operationUiKey(scenario.scenarioId, operationIndex) const resultKey = String(operation.resultKey ?? operation.name ?? `operation${operationIndex + 1}`) setError('') setOperationRunningKeys((current) => ({ ...current, [uiKey]: true })) try { const operationScenarioId = `${scenario.scenarioId}__OP_${operationIndex + 1}` const cleanupEnabled = cleanupEnabledForScenario(scenario.scenarioId) const operationScenario: ScenarioDefinition = { ...scenario, scenarioId: operationScenarioId, displayName: `${scenario.displayName || scenario.scenarioId} / ${operation.name ?? resultKey}`, operations: [operation], verification: { checks: [], rawResult: resultKey, decryptedResult: resultKey }, } const result = await runScenario(operationScenarioId, { scenario: operationScenario, overrides: { ...verificationOverrides([operationScenarioId]), cleanupInsertedRows: cleanupEnabled, cleanupInsertedRowsByScenario: { [operationScenarioId]: cleanupEnabled }, }, }) appendQueryLogsFromResult(result) const capture = firstExecutedQueryCapture(result, resultKey) if (capture) { setOperationQueryCaptures((current) => ({ ...current, [uiKey]: capture })) } setScenarioMessage(`오퍼레이션 실행 완료: ${scenario.scenarioId} / ${resultKey}`) } catch (error) { setError(error instanceof Error ? error.message : '오퍼레이션 실행 실패') } finally { setOperationRunningKeys((current) => ({ ...current, [uiKey]: false })) } } async function runAll() { setRunning(true) setError('') try { const result = await runVerification({ scenarios, overrides: verificationOverrides(), }) appendQueryLogsFromResult(result) const nextTree = recalculateTree([result]) setTree(nextTree) const ids = collectExpandableIds(nextTree) setExpandedIds(ids) await saveTreeState(ids) setSelected(result.children?.[0]?.children?.[0]?.children?.[0] ?? result) } catch (e) { setError(e instanceof Error ? e.message : '검증 실행 실패') } finally { setRunning(false) } } async function runTreeNode(node: VerificationNode) { setError('') try { if (node.type === 'SCENARIO') { const scenario = scenarios.find((item) => item.scenarioId === node.id) if (!scenario) throw new Error(`Scenario not found in Next.js JSON: ${node.id}`) const result = await runScenario(node.id, { scenario, overrides: verificationOverrides([node.id]), }) appendQueryLogsFromResult(result) setTree((items) => recalculateTree(replaceNode(items, result))) setSelected(result) return } const scenarioIds = collectScenarioIds(node) if (scenarioIds.length === 0) { setSelected(node) return } const results = await Promise.all(scenarioIds.map((scenarioId) => { const scenario = scenarios.find((item) => item.scenarioId === scenarioId) if (!scenario) throw new Error(`Scenario not found in Next.js JSON: ${scenarioId}`) return runScenario(scenarioId, { scenario, overrides: verificationOverrides([scenarioId]), }) })) results.forEach(appendQueryLogsFromResult) setTree((items) => recalculateTree(results.reduce((next, result) => replaceNode(next, result), items))) setSelected(results[0]) } catch (e) { setError(e instanceof Error ? e.message : '개별 검증 실패') } } async function runScenarioIdsSequentially(scenarioIds: string[]) { const uniqueIds = [...new Set(scenarioIds)].filter(Boolean) if (uniqueIds.length === 0 || scenarioRunning) return setScenarioRunning(true) setScenarioMessage('') setError('') try { for (const scenarioId of uniqueIds) { const scenario = scenarios.find((item) => item.scenarioId === scenarioId) if (!scenario) { setScenarioUiState((current) => ({ ...current, verificationHistory: { ...current.verificationHistory, [scenarioId]: { status: 'FAIL', progress: 100, updatedAt: new Date().toISOString(), label: scenarioId, exceptionMessage: `Scenario not found in Next.js JSON: ${scenarioId}`, }, }, })) continue } setScenarioUiState((current) => ({ ...current, verificationHistory: { ...current.verificationHistory, [scenarioId]: { ...(current.verificationHistory[scenarioId] ?? {}), status: 'RUNNING', progress: 0, updatedAt: new Date().toISOString(), label: scenario.displayName || scenario.scenarioId, }, }, })) try { const result = await runScenario(scenarioId, { scenario, overrides: verificationOverrides([scenarioId]), }) appendQueryLogsFromResult(result) const history: ScenarioVerificationHistory = { status: result.status, progress: result.progress ?? 100, updatedAt: new Date().toISOString(), label: result.label || scenario.displayName || scenario.scenarioId, elapsedMs: result.elapsedMs, exceptionMessage: result.exceptionMessage, details: result.details, } setScenarioUiState((current) => ({ ...current, verificationHistory: { ...current.verificationHistory, [scenarioId]: history, }, })) setTree((items) => recalculateTree(replaceNode(items, result))) setSelected(result) } catch (e) { setScenarioUiState((current) => ({ ...current, verificationHistory: { ...current.verificationHistory, [scenarioId]: { status: 'FAIL', progress: 100, updatedAt: new Date().toISOString(), label: scenario.displayName || scenario.scenarioId, exceptionMessage: e instanceof Error ? e.message : '검증 실패', }, }, })) } } setScenarioMessage(`순차 검증 완료: ${uniqueIds.length}건`) } finally { setScenarioRunning(false) } } function toggleNode(nodeId: string) { setExpandedIds((current) => { const next = current.includes(nodeId) ? current.filter((id) => id !== nodeId) : [...current, nodeId] saveTreeState(next) return next }) } function openContextMenu(node: VerificationNode, x: number, y: number) { setContextMenu({ node, x, y }) } function openTargetMenu(event: MouseEvent) { const rect = event.currentTarget.getBoundingClientRect() const maxLeft = window.innerWidth - targetMenuWidth - viewportMenuMargin setTargetMenu({ x: Math.max(viewportMenuMargin, Math.min(rect.right - targetMenuWidth, maxLeft)), y: Math.max(viewportMenuMargin, rect.top), }) } function syncTarget(index: number, plainColumn: string) { setTargets((items) => items.map((item, i) => { if (i !== index) return item const base = plainColumn.toUpperCase() const encryptTypeHandler = item.encryptTypeHandler || inferEncryptTypeHandler(base) return applyCryptoTypeHandlersToTarget({ ...item, plainColumn: base, encColumn: `${base}_ENC`, hashColumn: item.hashColumn ? `${base}_HASH` : '', javaProperty: toCamel(base), encryptTypeHandler, hashTypeHandler: item.hashTypeHandler || (item.hashColumn ? inferHashTypeHandler(base, encryptTypeHandler) : ''), }, cryptoTypeHandlerMappings) })) } function updateTarget(index: number, patch: Partial, applyHandlerMapping = false) { setTargets((items) => items.map((item, i) => { if (i !== index) return item const next = { ...item, ...patch } return applyHandlerMapping ? applyCryptoTypeHandlersToTarget(next, cryptoTypeHandlerMappings) : next })) } function addCryptoTypeHandlerMappingRow() { setCryptoTypeHandlerMappings((items) => [...items, emptyCryptoTypeHandlerMapping()]) } function updateCryptoTypeHandlerMapping(index: number, patch: Partial) { setCryptoTypeHandlerMappings((items) => { const next = items.map((item, i) => i === index ? normalizeCryptoTypeHandlerMapping({ ...item, ...patch }) : item) setTargets((currentTargets) => currentTargets.map((target) => applyCryptoTypeHandlersToTarget(target, next))) return next }) } function deleteCryptoTypeHandlerMappingRow(index: number) { setCryptoTypeHandlerMappings((items) => items.filter((_, i) => i !== index)) } function addTargetRow() { setTargets((items) => [...items, emptyTarget()]) } function deleteTargetRow(index: number) { setTargets((items) => items.filter((_, i) => i !== index)) } async function runCryptoColumnJob(action: ColumnAction, kind: ColumnKind) { const columnLabel = kind === 'encrypt' ? '암호화컬럼' : '해시컬럼' const actionLabel = action === 'add' ? '일괄추가' : '일괄삭제' if (action === 'drop' && !window.confirm(`${columnLabel}${actionLabel}를 실행할까요? DB 컬럼이 삭제됩니다.`)) return setTargetMessage(`${columnLabel}${actionLabel} 실행중...`) setTargetError('') setTargetMenu(undefined) setTargetLoadingMessage(`${columnLabel}${actionLabel} 실행 중...`) setColumnJob({ action, kind }) try { const response = await fetch('/api/db-add-crypto-columns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, kind, targets }), }) const payload = await response.json() as { message?: string summary?: { created: number, dropped: number, skipped: number, failed: number } results?: Array<{ tableName: string, newColumn: string, status: string, message?: string, columnType?: string }> } if (!response.ok) throw new Error(payload.message || `${columnLabel}${actionLabel} 실패`) const summary = payload.summary ?? { created: 0, dropped: 0, skipped: 0, failed: 0 } const changed = action === 'add' ? `생성 ${summary.created}개` : `삭제 ${summary.dropped}개` setTargetMessage(`${columnLabel}${actionLabel} 완료: ${changed}, 건너뜀 ${summary.skipped}개, 실패 ${summary.failed}개`) const failed = (payload.results ?? []).filter((result) => result.status === 'failed') setTargetError(failed.map((result) => `${result.tableName}.${result.newColumn}: ${result.message}`).join('\n')) } catch (e) { setTargetMessage('') setTargetError(e instanceof Error ? e.message : `${columnLabel}${actionLabel} 실패`) } finally { setColumnJob(null) setTargetLoadingMessage('') } } async function saveTargetsToJson() { setTargetMessage('') setTargetError('') setTargetLoadingMessage('암호화 대상 JSON 저장 중...') try { const [saved, savedMappings] = await Promise.all([ saveLocalTargets(targets), saveCryptoTypeHandlerMappings(cryptoTypeHandlerMappings), ]) setTargets(saved) setCryptoTypeHandlerMappings(savedMappings) setTargetMessage(`verify-next/data/crypto-targets.json 저장 완료: ${saved.length}개, verify-next/data/handler-mapping.json 저장 완료: ${savedMappings.length}개`) } catch (e) { setTargetError(e instanceof Error ? e.message : '암호화 대상 저장 실패') } finally { setTargetLoadingMessage('') } } async function importTargets(file: File | undefined) { if (!file) return setTargetMessage('') setTargetError('') setTargetLoadingMessage('암호화 대상 가져오는 중...') try { const parsed = parseCryptoTargetsText(await file.text()) if (!Array.isArray(parsed)) throw new Error('JSON 최상위 구조가 배열이 아닙니다.') const mappedParsed = parsed.map((target) => applyCryptoTypeHandlersToTarget(target, cryptoTypeHandlerMappings)) const saved = await saveLocalTargets(mergeCryptoTargets(targets, mappedParsed)) setTargets(saved) setTargetMessage(`JSON 불러오기 및 로컬 저장 완료: ${saved.length}개`) } catch (e) { setTargetError(e instanceof Error ? e.message : 'JSON 불러오기 실패') } finally { setTargetLoadingMessage('') if (fileInputRef.current) fileInputRef.current.value = '' } } async function autoDetectHashColumns() { setTargetMessage('') setTargetError('') setTargetLoadingMessage('해시컬럼 자동탐색 중...') try { const savedSettings = await saveSettings({ mapperRoot }) setMapperRoot(savedSettings.mapperRoot) const items = await loadMapperSqlIds() setMapperSqlItems(items) let detected = 0 const nextTargets = targets.map((target) => { if (!target.tableName || !target.plainColumn) return target const used = items.some((item) => columnUsedInWhere(item.xmlText, target.tableName, target.plainColumn)) if (!used) return target const hashColumn = target.hashColumn || `${target.plainColumn}_HASH` const hashTypeHandler = target.hashTypeHandler || inferHashTypeHandler(target.plainColumn, target.encryptTypeHandler) if (hashColumn !== target.hashColumn || hashTypeHandler !== target.hashTypeHandler) detected += 1 return applyCryptoTypeHandlersToTarget({ ...target, hashColumn, hashTypeHandler }, cryptoTypeHandlerMappings) }) const saved = await saveLocalTargets(nextTargets) setTargets(saved) setTargetMessage(`해쉬컬럼 자동탐색 완료: ${detected}개 갱신`) } catch (e) { setTargetError(e instanceof Error ? e.message : '해쉬컬럼 자동탐색 실패') } finally { setTargetLoadingMessage('') } } async function fetchRandomColumnSample(target: CryptoTarget, key: string, force = false) { if (!target.tableName || !target.plainColumn) return if (!force && randomSampleRequestedKeys.current.has(key)) return randomSampleRequestedKeys.current.add(key) setTargetSamples((current) => ({ ...current, [key]: { ...(current[key] ?? {}), loading: true, message: 'TOP 3 값 조회중' } })) const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), 12000) try { const response = await fetch(`/api/db-random-column-values?table=${encodeURIComponent(target.tableName)}&column=${encodeURIComponent(target.plainColumn)}`, { cache: 'no-store', signal: controller.signal }) const payload = await response.json() as { values?: string[], message?: string } if (!response.ok) throw new Error(payload.message || 'TOP 3 컬럼 값 조회 실패') setTargetSamples((current) => ({ ...current, [key]: { loading: false, values: payload.values ?? [], message: (payload.values ?? []).length > 0 ? undefined : '조회된 값 없음', }, })) } catch (e) { setTargetSamples((current) => ({ ...current, [key]: { loading: false, values: [], message: e instanceof Error && e.name === 'AbortError' ? 'TOP 3 컬럼 값 조회 시간 초과' : e instanceof Error ? e.message : 'TOP 3 컬럼 값 조회 실패' }, })) } finally { window.clearTimeout(timeoutId) } } async function saveScenarioJson() { setScenarioMessage('') const parsed = JSON.parse(scenarioJson) as ScenarioDefinition[] const saved = await saveScenarioFile(scenarioFileName, parsed) setScenarioFileName(saved.fileName) await saveScenarioUiState(scenarioUiState) setScenarioMessage(`${saved.fileName} 저장 완료`) } async function importScenarioJson(file: File | undefined) { if (!file) return setScenarioMessage('') try { const parsed = JSON.parse(await file.text()) as ScenarioDefinition[] if (!Array.isArray(parsed)) throw new Error('검증시나리오 JSON은 배열이어야 합니다.') const saved = await saveScenarioFile(file.name, parsed) setScenarioFileName(saved.fileName) setScenarioJson(JSON.stringify(saved.scenarios, null, 2)) const nextUiState = emptyScenarioUiState setScenarioUiState(nextUiState) await saveScenarioUiState(nextUiState) setTree((items) => syncTreeWithScenarios(items, saved.scenarios)) setScenarioMessage(`${saved.fileName} 로드 완료`) } catch (e) { setScenarioMessage(e instanceof Error ? e.message : '시나리오 JSON 로드 실패') } finally { if (scenarioFileInputRef.current) scenarioFileInputRef.current.value = '' } } function updateScenario(index: number, patch: Partial) { const shouldRegenerate = [ 'scenarioType', 'targetTable', 'targetColumn', 'encColumn', 'hashColumn', 'javaProperty', 'mapperFile', 'sqlId', 'sqlCommandType', 'encryptTypeHandler', 'hashTypeHandler', ].some((key) => Object.prototype.hasOwnProperty.call(patch, key)) const next = scenarios.map((scenario, i) => { if (i !== index) return scenario const merged: ScenarioDefinition = { ...scenario, ...patch } if (patch.targetTable || patch.targetColumn) { const target = targets.find((item) => item.tableName === merged.targetTable && item.plainColumn === merged.targetColumn) if (target) { merged.encColumn = target.encColumn merged.hashColumn = target.hashColumn merged.javaProperty = target.javaProperty merged.encryptTypeHandler = target.encryptTypeHandler merged.hashTypeHandler = target.hashTypeHandler } else if (patch.targetColumn) { const base = merged.targetColumn.toUpperCase() merged.targetColumn = base merged.encColumn = `${base}_ENC` merged.hashColumn = `${base}_HASH` merged.javaProperty = toCamel(base) } } if (patch.scenarioType || patch.targetTable || patch.targetColumn || patch.javaProperty) { merged.defaultParameters = { ...(merged.defaultParameters ?? {}), ...defaultParametersForScenario(merged), } } if (shouldRegenerate) { return regenerateScenarioExecution(merged) } return merged }) setScenarioJson(JSON.stringify(next, null, 2)) } function addScenario(seed: ScenarioSeed) { const inferredTarget = inferTargetFromXml(seed.xmlText, targets) const target = targets.find((item) => item.tableName === seed.targetTable && item.plainColumn === seed.targetColumn) ?? inferredTarget ?? targets[0] const mapperBase = seed.mapperFile || 'ManualMapper.xml' const sqlBase = seed.sqlId || 'manualSqlId' const timestamp = Date.now() const inferredScenarioType = inferScenarioType(seed.sqlCommandType, seed.xmlText) const seedCommand = (seed.sqlCommandType || '').toUpperCase() const scenarioType = seedCommand === 'INSERT' || seedCommand === 'UPDATE' ? inferredScenarioType : seed.scenarioType || inferredScenarioType const nextScenarioBase: ScenarioDefinition = { scenarioId: `MANUAL_${sanitizeId(mapperBase)}_${sanitizeId(sqlBase)}_${timestamp}`, displayName: `${sqlBase} ${scenarioTypeLabel(scenarioType)}`, mapperFile: mapperBase, sqlId: sqlBase, sqlCommandType: seed.sqlCommandType || 'UPDATE', scenarioType, targetTable: seed.targetTable || target?.tableName || '', targetColumn: seed.targetColumn || target?.plainColumn || '', encColumn: seed.encColumn || target?.encColumn || '', hashColumn: seed.hashColumn || target?.hashColumn || '', javaProperty: seed.javaProperty || target?.javaProperty || '', encryptTypeHandler: seed.encryptTypeHandler || target?.encryptTypeHandler || '', hashTypeHandler: seed.hashTypeHandler || target?.hashTypeHandler || '', executionMode: scenarioType === 'TYPEHANDLER_MISSING_DETECTION' ? undefined : 'SQL_SESSION', defaultParameters: {}, } nextScenarioBase.defaultParameters = { ...defaultParametersForScenario(nextScenarioBase), ...(seed.defaultParameters ?? {}), } const nextScenario: ScenarioDefinition = { ...nextScenarioBase, operations: hasOperations(seed.operations) ? seed.operations : defaultOperationsForScenario(nextScenarioBase), verification: seed.verification && Object.keys(seed.verification).length > 0 ? seed.verification : defaultVerificationForScenario(nextScenarioBase), } setScenarioJson(JSON.stringify([...scenarios, nextScenario], null, 2)) setScenarioMessage(`${mapperBase} · ${sqlBase} row 추가 완료`) } function deleteScenario(index: number) { const scenario = scenarios[index] if (!scenario) return const next = scenarios.filter((_, i) => i !== index) setScenarioJson(JSON.stringify(next, null, 2)) setScenarioMessage(`${scenario.displayName || scenario.scenarioId} row 삭제 완료`) setScenarioUiState((current) => { const { [scenario.scenarioId]: _removed, ...verificationHistory } = current.verificationHistory return { ...current, verificationHistory } }) if (selected?.id === scenario.scenarioId) { setSelected(undefined) } setTree((items) => removeScenarioFromTree(items, scenario.scenarioId)) } async function refreshMapperSqlItems() { setMapperSqlItems(await loadMapperSqlIds()) } async function saveMapperRootSetting() { setSettingsMessage('') setSettingsError('') try { const saved = await saveSettings({ mapperRoot }) setMapperRoot(saved.mapperRoot) await refreshMapperSqlItems() await refreshRewrite() setSettingsMessage('Mapper XML 루트 경로 저장 및 SQL ID 재스캔 완료') } catch (e) { setSettingsError(e instanceof Error ? e.message : '설정 저장 실패') } } async function rescanAndGenerateScenarios() { const confirmed = window.confirm('기존 검증 정보들을 백업하세요. 확인하면 현재 검증시나리오 JSON과 검증 기록이 덮어써서 지워집니다.') if (!confirmed) return setScenarioMessage('') try { const savedSettings = await saveSettings({ mapperRoot }) setMapperRoot(savedSettings.mapperRoot) const items = await loadMapperSqlIds() setMapperSqlItems(items) const generated = discoverScenariosFromMappers(items, targets) const nextUiState = emptyScenarioUiState setScenarioJson(JSON.stringify(generated, null, 2)) setScenarioUiState(nextUiState) await saveScenarioFile(scenarioFileName, generated) await saveScenarioUiState(nextUiState) setTree((items) => syncTreeWithScenarios(items, generated)) setScenarioMessage(`Mapper 재탐색 및 검증시나리오 자동 생성 완료: ${generated.length}개`) } catch (e) { setScenarioMessage(e instanceof Error ? e.message : 'Mapper 재탐색 및 시나리오 자동 생성 실패') } } async function cleanAndGenerateScenarios() { const confirmed = window.confirm('Clean 후 현재 crypto-targets.json 기준으로 시나리오 JSON을 완전히 새로 생성합니다. 기존 시나리오와 검증 기록은 초기화됩니다.') if (!confirmed) return setScenarioMessage('') try { const savedSettings = await saveSettings({ mapperRoot }) setMapperRoot(savedSettings.mapperRoot) const [latestTargets, items] = await Promise.all([ loadLocalTargets(), loadMapperSqlIds(), ]) setTargets(latestTargets) setMapperSqlItems(items) const generated = discoverScenariosFromMappers(items, latestTargets) const nextUiState = emptyScenarioUiState setSelected(undefined) setTree((items) => syncTreeWithScenarios(items, generated)) setScenarioJson(JSON.stringify(generated, null, 2)) setScenarioUiState(nextUiState) await saveScenarioFile(scenarioFileName, generated) await saveScenarioUiState(nextUiState) setScenarioMessage(`Clean 완료: 현재 crypto-targets.json 기준으로 ${generated.length}개 시나리오를 새로 생성했습니다.`) } catch (e) { setScenarioMessage(e instanceof Error ? e.message : 'Clean 및 시나리오 재생성 실패') } } async function refreshRewrite() { setError('') try { setRewrites(await loadRewriteCandidates()) } catch (e) { setError(e instanceof Error ? e.message : '치환 후보 조회 실패') } } async function selectRewrite(candidate: RewriteCandidate) { const preview = await previewRewrite(candidate) setSelectedRewrite(preview) setRewriteAfterSql(preview.afterSql) } async function applySelectedRewrite() { if (!selectedRewrite) return const applied = await applyRewrite({ ...selectedRewrite, afterSql: rewriteAfterSql }) setSelectedRewrite(applied) setRewriteAfterSql(applied.afterSql) } async function openRewritePopup(node: VerificationNode) { setError('') try { const mapperName = node.mapperName || findMapperName(node) const sqlId = node.sqlId || findSqlId(node) const candidate = rewrites.find((item) => item.mapperFile === mapperName && item.sqlId === sqlId) if (!candidate) { setError(`치환 후보가 없습니다. mapper=${mapperName || '-'} sqlId=${sqlId || '-'}`) return } await selectRewrite(candidate) } catch (e) { setError(e instanceof Error ? e.message : '치환 미리보기 실패') } } useEffect(() => { const runId = makeVerificationRunId() setVerificationRunId(runId) setPlainValue(`홍길동_${runId}`) setTargetLoadingMessage('암호화 대상 로딩 중...') loadCryptoTypeHandlerMappings() .then((mappings) => { setCryptoTypeHandlerMappings(mappings) return loadLocalTargets().then((items) => setTargets(items.map((target) => applyCryptoTypeHandlersToTarget(target, mappings)))) }) .catch((e) => { setCryptoTypeHandlerMappings([]) return loadLocalTargets().then(setTargets).catch(() => setTargetError(e instanceof Error ? e.message : '암호화 대상 로드 실패')) }) .finally(() => setTargetLoadingMessage('')) loadScenarioFile() .then((payload) => { setScenarioFileName(payload.fileName) setScenarioJson(JSON.stringify(payload.scenarios, null, 2)) }) .catch(() => setScenarioJson('[]')) loadMapperSqlIds().then(setMapperSqlItems).catch(() => setMapperSqlItems([])) loadSettings().then((settings) => setMapperRoot(settings.mapperRoot)).catch(() => setMapperRoot('src/main/resources/mapper/business')) loadTreeState().then((state) => setExpandedIds(state.expandedIds?.length ? state.expandedIds : ['root'])) loadScenarioUiState() .then((state) => setScenarioUiState({ ...emptyScenarioUiState, ...state, verificationHistory: state.verificationHistory ?? {}, cleanupInsertedRows: state.cleanupInsertedRows ?? true, cleanupInsertedRowsByScenario: state.cleanupInsertedRowsByScenario ?? {}, })) .catch(() => setScenarioUiState(emptyScenarioUiState)) .finally(() => setScenarioUiStateLoaded(true)) refreshRewrite() }, []) useEffect(() => { if (!scenarioUiStateLoaded) return const timeout = window.setTimeout(() => { saveScenarioUiState(scenarioUiState).catch(() => undefined) }, 700) return () => window.clearTimeout(timeout) }, [scenarioUiState, scenarioUiStateLoaded]) useEffect(() => { window.localStorage.setItem(queryLogStorageKey, JSON.stringify(queryLogs)) const channel = new BroadcastChannel(queryLogChannelName) channel.postMessage(queryLogs) channel.close() }, [queryLogs]) useEffect(() => { if (!scenarioJson.trim()) return const timeout = window.setTimeout(() => { try { const parsed = JSON.parse(scenarioJson) as ScenarioDefinition[] if (Array.isArray(parsed)) { saveScenarioFile(scenarioFileName, parsed).catch(() => undefined) } } catch { // Keep the editor responsive while the user is in the middle of typing invalid JSON. } }, 700) return () => window.clearTimeout(timeout) }, [scenarioJson, scenarioFileName]) useEffect(() => { setTree((items) => syncTreeWithScenarios(items, scenarios)) }, [scenarios]) useEffect(() => { const timeout = window.setTimeout(() => { targets.forEach((target, index) => { const sampleKey = `${index}:${target.tableName}:${target.plainColumn}` if (target.tableName && target.plainColumn && !randomSampleRequestedKeys.current.has(sampleKey)) { void fetchRandomColumnSample(target, sampleKey) } }) }, 500) return () => window.clearTimeout(timeout) }, [targets]) return (
{error &&
{error}
} {contextMenu && (
setContextMenu(undefined)} >
)} {targetMenu && (
setTargetMenu(undefined)} >
)} {targetLoadingMessage && (
)} {optionGuide && (
setOptionGuide(undefined)}>
event.stopPropagation()}>

{optionGuide.title}

{optionGuide.description}

검증 과정

    {optionGuide.steps.map((step) =>
  1. {step}
  2. )}
{!!optionGuide.checks?.length && ( <>

통과 기준

    {optionGuide.checks.map((check) =>
  • {check}
  • )}
)}
)} {tab === 'verify' && ( <>
setVerificationRunId(e.target.value)} />
setPlainValue(e.target.value)} />
{selected ? ( <>

{selected.label}

{selected.mapperName || '-'} · {selected.sqlId || '-'}

{selected.status}
{selected.exceptionMessage &&
{localizedFailureMessage({ status: 'FAIL', progress: 100, updatedAt: '', exceptionMessage: selected.exceptionMessage, details: selected.details })}
} {detailJson &&
{detailJson}
} ) : (
상세 정보가 없습니다. 검증을 실행한 뒤 트리 노드를 선택하세요.
)}
)} {tab === 'targets' && (

암호화 대상 컬럼

Spring 서버가 아니라 Next.js 로컬 JSON 파일에 저장됩니다.

importTargets(e.target.files?.[0])} />

cryptoType Handler 매핑

암호화 타입별 Encrypt Handler와 Hash Handler를 지정합니다.

{cryptoTypeHandlerMappings.map((mapping, index) => ( ))} {cryptoTypeHandlerMappings.length === 0 && ( )}
cryptoTypeEncrypt HandlerHash Handler삭제
updateCryptoTypeHandlerMapping(index, { cryptoType: e.target.value })} /> updateCryptoTypeHandlerMapping(index, { encryptTypeHandler: e.target.value })} /> updateCryptoTypeHandlerMapping(index, { hashTypeHandler: e.target.value })} />
등록된 cryptoType handler 매핑이 없습니다.
{targetMessage &&
{targetMessage}
} {targetError &&
{targetError}
}
{targets.map((target, index) => { const sampleKey = `${index}:${target.tableName}:${target.plainColumn}` const sample = targetSamples[sampleKey] const sampleText = sample?.loading ? '조회중...' : sample?.message || sample?.values?.join(', ') || '-' return ( ) })}
테이블평문 컬럼암호화 타입암호 컬럼해시 컬럼Java PropertyEncrypt HandlerHash Handler삭제
setTargets((items) => items.map((item, i) => i === index ? { ...item, tableName: e.target.value.toUpperCase() } : item))} /> syncTarget(index, e.target.value)} /> updateTarget(index, { cryptoType: e.target.value }, true)} /> setTargets((items) => items.map((item, i) => i === index ? { ...item, encColumn: e.target.value.toUpperCase() } : item))} /> updateTarget(index, { hashColumn: e.target.value.toUpperCase() }, true)} /> setTargets((items) => items.map((item, i) => i === index ? { ...item, javaProperty: e.target.value } : item))} /> setTargets((items) => items.map((item, i) => i === index ? { ...item, encryptTypeHandler: e.target.value } : item))} /> setTargets((items) => items.map((item, i) => i === index ? { ...item, hashTypeHandler: e.target.value } : item))} />
TOP 3 ROW 값 {sampleText}
)} {tab === 'scenarios' && (

검증 시나리오 JSON

현재 검증 엔진 시나리오는 Spring API와 연동됩니다.

현재 파일: {scenarioFileName}
importScenarioJson(e.target.files?.[0])} />
{scenarioMessage &&
{scenarioMessage}
} {scenarioViewMode === 'json' ? (