diff options
Diffstat (limited to 'app/api')
| -rw-r--r-- | app/api/auth/[...nextauth]/route.ts | 258 | ||||
| -rw-r--r-- | app/api/auth/first-auth/route.ts | 112 | ||||
| -rw-r--r-- | app/api/auth/send-sms/route.ts | 20 | ||||
| -rw-r--r-- | app/api/auth/verify-mfa/route.ts | 21 | ||||
| -rw-r--r-- | app/api/files/[...path]/route.ts | 244 | ||||
| -rw-r--r-- | app/api/ocr/utils/tableExtraction.ts | 648 | ||||
| -rw-r--r-- | app/api/vendors/route.ts | 248 |
7 files changed, 1354 insertions, 197 deletions
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index f5d49f77..2b168746 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,4 @@ -// Updated NextAuth configuration with dynamic session timeout from database - +// auth/config.ts - 업데이트된 NextAuth 설정 import NextAuth, { NextAuthOptions, Session, @@ -9,15 +8,18 @@ import NextAuth, { import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' import { SAMLProvider } from './saml/provider' -import { getUserById } from '@/lib/users/repository' +import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' +import { verifySmsToken } from '@/lib/users/auth/passwordUtil' +import { SessionRepository } from '@/lib/users/session/repository' +import { loginSessions } from '@/db/schema' // 인증 방식 타입 정의 type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' -// 모듈 보강 선언 (인증 방식 추가) +// 모듈 보강 선언 (기존과 동일) declare module "next-auth" { interface Session { user: { @@ -30,7 +32,8 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null // DB 세션 ID 추가 } } @@ -42,6 +45,7 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod + dbSessionId?: string | null } } @@ -54,11 +58,12 @@ declare module "next-auth/jwt" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null } } -// 보안 설정 캐시 (성능 최적화) +// 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null lastFetch: number @@ -69,7 +74,6 @@ let securitySettingsCache: { ttl: 5 * 60 * 1000 // 5분 캐시 } -// 보안 설정을 가져오는 함수 (캐시 적용) async function getCachedSecuritySettings() { const now = Date.now() @@ -80,7 +84,6 @@ async function getCachedSecuritySettings() { securitySettingsCache.lastFetch = now } catch (error) { console.error('Failed to fetch security settings:', error) - // 기본값 사용 securitySettingsCache.data = { sessionTimeoutMinutes: 480 // 8시간 기본값 } @@ -90,11 +93,28 @@ async function getCachedSecuritySettings() { return securitySettingsCache.data } +// 클라이언트 IP 추출 헬퍼 +function getClientIP(req: any): string { + const forwarded = req.headers['x-forwarded-for'] + const realIP = req.headers['x-real-ip'] + + if (forwarded) { + return forwarded.split(',')[0].trim() + } + + if (realIP) { + return realIP + } + + return req.ip || req.connection?.remoteAddress || '127.0.0.1' +} + export const authOptions: NextAuthOptions = { providers: [ - // OTP provider + // OTP 로그인 (기존 유지) CredentialsProvider({ - name: 'Credentials', + id: 'credentials-otp', + name: 'OTP', credentials: { email: { label: 'Email', type: 'text' }, code: { label: 'OTP code', type: 'text' }, @@ -107,9 +127,7 @@ export const authOptions: NextAuthOptions = { return null } - // 보안 설정에서 세션 타임아웃 가져오기 const securitySettings = await getCachedSecuritySettings() - const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 const reAuthTime = Date.now() return { @@ -125,61 +143,101 @@ export const authOptions: NextAuthOptions = { } }, }), - - // ID/패스워드 provider (S-Gips와 일반 이메일 구분) + + // MFA 완료 후 최종 인증 (DB 연동 버전) CredentialsProvider({ - id: 'credentials-password', - name: 'Username Password', + id: 'credentials-mfa', + name: 'MFA Verification', credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - provider: { label: "Provider", type: "text" }, + userId: { label: 'User ID', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, + tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, }, async authorize(credentials, req) { - if (!credentials?.username || !credentials?.password) { - return null; + if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { + console.error('MFA credentials missing') + return null } - + try { - let authResult; - const isSSgips = credentials.provider === 'sgips'; - - if (isSSgips) { - authResult = await authenticateWithSGips( - credentials.username, - credentials.password - ); - } else { - authResult = await verifyExternalCredentials( - credentials.username, - credentials.password - ); + // DB에서 임시 인증 정보 확인 + const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) + if (!tempAuth || tempAuth.userId !== credentials.userId) { + console.error('Temp auth expired or not found') + return null } - - if (authResult.success && authResult.user) { - return { - id: authResult.user.id, - name: authResult.user.name, - email: authResult.user.email, - imageUrl: authResult.user.imageUrl ?? null, - companyId: authResult.user.companyId, - techCompanyId: authResult.user.techCompanyId, - domain: authResult.user.domain, - reAuthTime: Date.now(), - authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod, - }; + + // SMS 토큰 검증 + const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken) + if (!smsVerificationResult || !smsVerificationResult.success) { + console.error('SMS token verification failed') + return null } - return null; + // 사용자 정보 조회 + const user = await getUserById(Number(credentials.userId)) + if (!user) { + console.error('User not found after MFA verification') + return null + } + + // 임시 인증 정보를 사용됨으로 표시 + await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey) + + // 보안 설정 및 세션 정보 설정 + const securitySettings = await getCachedSecuritySettings() + const reAuthTime = Date.now() + const sessionExpiredAt = new Date(reAuthTime + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + // DB에 로그인 세션 생성 + const ipAddress = getClientIP(req) + const userAgent = req.headers?.['user-agent'] + const dbSession = await SessionRepository.createLoginSession({ + userId: String(user.id), + ipAddress, + userAgent, + authMethod: tempAuth.authMethod, + sessionExpiredAt, + }) + + console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) + + return { + id: String(user.id), + email: user.email, + imageUrl: user.imageUrl ?? null, + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId as number | undefined, + domain: user.domain, + reAuthTime, + authMethod: tempAuth.authMethod as AuthMethod, + dbSessionId: dbSession.id, + } + } catch (error) { - console.error("Authentication error:", error); - return null; + console.error('MFA authorization error:', error) + return null } + }, + }), + + // 1차 인증용 프로바이더 (기존 유지) + CredentialsProvider({ + id: 'credentials-first-auth', + name: 'First Factor Authentication', + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + provider: { label: "Provider", type: "text" }, + }, + async authorize(credentials, req) { + return null } }), - // SAML Provider + // SAML Provider (기존 유지) SAMLProvider({ id: "credentials-saml", name: "SAML SSO", @@ -199,18 +257,15 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', - // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리 maxAge: 30 * 24 * 60 * 60, // 30일 }, callbacks: { - // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서) async jwt({ token, user, account, trigger, session }) { - // 보안 설정 가져오기 const securitySettings = await getCachedSecuritySettings() const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 - // 최초 로그인 시 + // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() token.id = user.id @@ -223,34 +278,44 @@ export const authOptions: NextAuthOptions = { token.reAuthTime = reAuthTime token.authMethod = user.authMethod token.sessionExpiredAt = reAuthTime + sessionTimeoutMs + token.dbSessionId = user.dbSessionId } - // 인증 방식 결정 (account 정보 기반) - if (account && !token.authMethod) { + // SAML 인증 시 DB 세션 생성 + if (account && account.provider === 'credentials-saml' && token.id) { const reAuthTime = Date.now() - if (account.provider === 'credentials-saml') { + const sessionExpiredAt = new Date(reAuthTime + sessionTimeoutMs) + + try { + const dbSession = await SessionRepository.createLoginSession({ + userId: token.id, + ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적 + authMethod: 'saml', + sessionExpiredAt, + }) + token.authMethod = 'saml' token.reAuthTime = reAuthTime token.sessionExpiredAt = reAuthTime + sessionTimeoutMs - } else if (account.provider === 'credentials') { - // OTP는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } - } else if (account.provider === 'credentials-password') { - // credentials-password는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } + token.dbSessionId = dbSession.id + } catch (error) { + console.error('Failed to create SAML session:', error) } } - // 세션 업데이트 시 (재인증 시간 업데이트) + // 세션 업데이트 시 if (trigger === "update" && session) { if (session.reAuthTime !== undefined) { token.reAuthTime = session.reAuthTime - // 재인증 시간 업데이트 시 세션 만료 시간도 연장 token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs + + // DB 세션 업데이트 + if (token.dbSessionId) { + await SessionRepository.updateLoginSession(token.dbSessionId, { + lastActivityAt: new Date(), + sessionExpiredAt: new Date(session.reAuthTime + sessionTimeoutMs) + }) + } } if (session.user) { @@ -263,14 +328,18 @@ export const authOptions: NextAuthOptions = { return token }, - // Session 콜백 - 세션 만료 체크 및 정보 포함 async session({ session, token }: { session: Session; token: JWT }) { // 세션 만료 체크 if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) { console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`) - // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도 + + // DB 세션 만료 처리 + if (token.dbSessionId) { + await SessionRepository.logoutSession(token.dbSessionId) + } + return { - expires: new Date(0).toISOString(), // 즉시 만료 + expires: new Date(0).toISOString(), user: null as any } } @@ -287,12 +356,12 @@ export const authOptions: NextAuthOptions = { reAuthTime: token.reAuthTime as number | null, authMethod: token.authMethod as AuthMethod, sessionExpiredAt: token.sessionExpiredAt as number | null, + dbSessionId: token.dbSessionId as string | null, } } return session }, - // Redirect 콜백 async redirect({ url, baseUrl }) { if (url.startsWith("/")) { return `${baseUrl}${url}`; @@ -309,18 +378,45 @@ export const authOptions: NextAuthOptions = { error: '/auth/error', }, - // 디버깅을 위한 이벤트 로깅 events: { async signIn({ user, account, profile }) { const securitySettings = await getCachedSecuritySettings() console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`); + + // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 + if (account?.provider !== 'credentials-mfa' && user.id) { + try { + // 기존 활성 세션 확인 + const existingSession = await SessionRepository.getActiveSessionByUserId(user.id) + if (!existingSession) { + const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + await SessionRepository.createLoginSession({ + userId: user.id, + ipAddress: '0.0.0.0', // signIn 이벤트에서는 IP 접근 제한적 + authMethod: user.authMethod || 'unknown', + sessionExpiredAt, + }) + } + } catch (error) { + console.error('Failed to create session in signIn event:', error) + } + } }, + async signOut({ session, token }) { console.log(`User ${session?.user?.email || token?.email} signed out`); + + // DB에서 세션 로그아웃 처리 + const userId = session?.user?.id || token?.id + const dbSessionId = session?.user?.dbSessionId || token?.dbSessionId + + if (dbSessionId) { + await SessionRepository.logoutSession(dbSessionId) + } else if (userId) { + // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 + await SessionRepository.logoutAllUserSessions(userId) + } } } } - -const handler = NextAuth(authOptions) -export { handler as GET, handler as POST } - diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts new file mode 100644 index 00000000..18f44904 --- /dev/null +++ b/app/api/auth/first-auth/route.ts @@ -0,0 +1,112 @@ +// /api/auth/first-auth/route.ts +// 1차 인증 처리 API 엔드포인트 + +import { NextRequest, NextResponse } from 'next/server' +import { authHelpers } from '../[...nextauth]/route' + +// 요청 데이터 타입 +interface FirstAuthRequest { + username: string + password: string + provider: 'email' | 'sgips' +} + +// 응답 데이터 타입 +interface FirstAuthResponse { + success: boolean + tempAuthKey?: string + userId?: string + email?: string + error?: string +} + +export async function POST(request: NextRequest): Promise<NextResponse<FirstAuthResponse>> { + try { + // 요청 데이터 파싱 + const body: FirstAuthRequest = await request.json() + const { username, password, provider } = body + + // 입력 검증 + if (!username || !password || !provider) { + return NextResponse.json( + { + success: false, + error: '필수 입력값이 누락되었습니다.' + }, + { status: 400 } + ) + } + + if (!['email', 'sgips'].includes(provider)) { + return NextResponse.json( + { + success: false, + error: '지원하지 않는 인증 방식입니다.' + }, + { status: 400 } + ) + } + + // 레이트 리미팅 (옵셔널) + // const rateLimitResult = await rateLimit.check(request, `first-auth:${username}`) + // if (!rateLimitResult.success) { + // return NextResponse.json( + // { + // success: false, + // error: '너무 많은 시도입니다. 잠시 후 다시 시도해주세요.' + // }, + // { status: 429 } + // ) + // } + + // 1차 인증 수행 + const authResult = await authHelpers.performFirstAuth(username, password, provider) + + if (!authResult.success) { + // 인증 실패 응답 + let errorMessage = '인증에 실패했습니다.' + + if (provider === 'sgips') { + errorMessage = 'S-Gips 계정 정보가 올바르지 않습니다.' + } else { + errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.' + } + + return NextResponse.json( + { + success: false, + error: authResult.error || errorMessage + }, + { status: 401 } + ) + } + + // 1차 인증 성공 응답 + return NextResponse.json({ + success: true, + tempAuthKey: authResult.tempAuthKey, + userId: authResult.userId, + email: authResult.email + }) + + } catch (error) { + console.error('First auth API error:', error) + + // 에러 응답 + return NextResponse.json( + { + success: false, + error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + { status: 500 } + ) + } +} + +// GET 요청은 지원하지 않음 +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed' }, + { status: 405 } + ) +}
\ No newline at end of file diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts index 3d51d445..6b9eb114 100644 --- a/app/api/auth/send-sms/route.ts +++ b/app/api/auth/send-sms/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { getUserById } from '@/lib/users/repository'; +import { getUserByEmail, getUserById } from '@/lib/users/repository'; import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil'; const sendSmsSchema = z.object({ @@ -13,20 +13,14 @@ const sendSmsSchema = z.object({ export async function POST(request: NextRequest) { try { - // 세션 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { error: '인증이 필요합니다' }, - { status: 401 } - ); - } const body = await request.json(); const { userId } = sendSmsSchema.parse(body); + console.log(userId, "userId") + // 본인 확인 - if (session.user.id !== userId) { + if (!userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } @@ -42,8 +36,12 @@ export async function POST(request: NextRequest) { ); } + console.log(user, "user") + + + // SMS 전송 - const result = await generateAndSendSmsToken(parseInt(userId), user.phone); + const result = await generateAndSendSmsToken(Number(userId), user.phone); if (result.success) { return NextResponse.json({ diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts index f9d1b51e..dea06164 100644 --- a/app/api/auth/verify-mfa/route.ts +++ b/app/api/auth/verify-mfa/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { verifySmsToken } from '@/lib/users/auth/passwordUtil'; +import { getUserByEmail } from '@/lib/users/repository'; const verifyMfaSchema = z.object({ userId: z.string(), @@ -25,16 +26,32 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { userId, token } = verifyMfaSchema.parse(body); + + console.log(userId) + + + // 본인 확인 - if (session.user.id !== userId) { + if (session.user.email !== userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } ); } + const user = await getUserByEmail(userId); + if (!user || !user.phone) { + return NextResponse.json( + { error: '전화번호가 등록되지 않았습니다' }, + { status: 400 } + ); + } + + const userIdfromUsers = user.id + + // MFA 토큰 검증 - const result = await verifySmsToken(parseInt(userId), token); + const result = await verifySmsToken(userIdfromUsers, token); if (result.success) { return NextResponse.json({ diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index f92dd1d8..e03187e3 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,74 +1,216 @@ // app/api/files/[...path]/route.ts -import { NextRequest, NextResponse } from 'next/server' -import { readFile } from 'fs/promises' -import { join } from 'path' -import { stat } from 'fs/promises' +// /nas_evcp 경로에서 파일을 서빙하는 API (다운로드 강제 기능 추가) + +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +const nasPath = process.env.NAS_PATH || "/evcp_nas" + +// MIME 타입 매핑 +const getMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record<string, string> = { + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.txt': 'text/plain', + '.zip': 'application/zip', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}; + +// 보안: 허용된 디렉토리 체크 +const isAllowedPath = (requestedPath: string): boolean => { + const allowedPaths = [ + 'basicContract', + 'basicContract/template', + 'basicContract/signed', + 'vendorFormReportSample', + 'vendorFormData', + ]; + + return allowedPaths.some(allowed => + requestedPath.startsWith(allowed) || requestedPath === allowed + ); +}; export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { try { + // 요청된 파일 경로 구성 + const requestedPath = params.path.join('/'); + + console.log(`📂 파일 요청: ${requestedPath}`); + + // ✅ 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + console.log(`📥 다운로드 강제 모드: ${forceDownload}`); + + // 보안 체크: 허용된 경로인지 확인 + if (!isAllowedPath(requestedPath)) { + console.log(`❌ 허용되지 않은 경로: ${requestedPath}`); + return new NextResponse('Forbidden', { status: 403 }); + } + + // 경로 트래버설 공격 방지 + if (requestedPath.includes('..') || requestedPath.includes('~')) { + console.log(`❌ 위험한 경로 패턴: ${requestedPath}`); + return new NextResponse('Bad Request', { status: 400 }); + } - const path = request.nextUrl.searchParams.get("path"); + // 환경에 따른 파일 경로 설정 + let filePath: string; + + if (process.env.NODE_ENV === 'production') { + // ✅ 프로덕션: NAS 경로 사용 + filePath = path.join(nasPath, requestedPath); + } else { + // 개발: public 폴더 + filePath = path.join(process.cwd(), 'public', requestedPath); + } + console.log(`📁 실제 파일 경로: ${filePath}`); - // 경로 파라미터에서 파일 경로 조합 - const filePath = join(process.cwd(), 'uploads', ...params.path) - // 파일 존재 여부 확인 try { - await stat(filePath) - } catch (error) { - return NextResponse.json( - { error: 'File not found' }, - { status: 404 } - ) + await fs.access(filePath); + } catch { + console.log(`❌ 파일 없음: ${filePath}`); + return new NextResponse('File not found', { status: 404 }); } - + + // 파일 통계 정보 가져오기 + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + console.log(`❌ 파일이 아님: ${filePath}`); + return new NextResponse('Not a file', { status: 400 }); + } + // 파일 읽기 - const fileBuffer = await readFile(filePath) + const fileBuffer = await fs.readFile(filePath); - // 파일 확장자에 따른 MIME 타입 설정 - const fileName = params.path[params.path.length - 1] - const fileExtension = fileName.split('.').pop()?.toLowerCase() + // MIME 타입 결정 + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); + + // ✅ Content-Disposition 헤더 결정 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + // Range 요청 처리 (큰 파일의 부분 다운로드 지원) + const range = request.headers.get('range'); - let contentType = 'application/octet-stream' + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + const chunksize = (end - start) + 1; + const chunk = fileBuffer.slice(start, end + 1); + + return new NextResponse(chunk, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${stats.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + }, + }); + } + + // 일반 파일 응답 + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + // ✅ 추가 보안 헤더 + 'X-Content-Type-Options': 'nosniff', + }, + }); + + } catch (error) { + console.error('❌ 파일 서빙 오류:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +// HEAD 요청 지원 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + const requestedPath = params.path.join('/'); - if (fileExtension) { - const mimeTypes: Record<string, string> = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - } - - contentType = mimeTypes[fileExtension] || contentType + // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + if (!isAllowedPath(requestedPath)) { + return new NextResponse(null, { status: 403 }); } - // 다운로드 설정 - const headers = new Headers() - headers.set('Content-Type', contentType) - headers.set('Content-Disposition', `attachment; filename="${fileName}"`) + if (requestedPath.includes('..') || requestedPath.includes('~')) { + return new NextResponse(null, { status: 400 }); + } + + let filePath: string; - return new NextResponse(fileBuffer, { - status: 200, - headers, - }) + if (process.env.NODE_ENV === 'production') { + filePath = path.join(nasPath, requestedPath); + } else { + filePath = path.join(process.cwd(), 'public', requestedPath); + } + + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + return new NextResponse(null, { status: 400 }); + } + + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + return new NextResponse(null, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Last-Modified': stats.mtime.toUTCString(), + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch { + return new NextResponse(null, { status: 404 }); + } + } catch (error) { - console.error('Error downloading file:', error) - return NextResponse.json( - { error: 'Failed to download file' }, - { status: 500 } - ) + console.error('File HEAD error:', error); + return new NextResponse(null, { status: 500 }); } }
\ No newline at end of file diff --git a/app/api/ocr/utils/tableExtraction.ts b/app/api/ocr/utils/tableExtraction.ts index 720e5a5f..0a727f84 100644 --- a/app/api/ocr/utils/tableExtraction.ts +++ b/app/api/ocr/utils/tableExtraction.ts @@ -69,37 +69,107 @@ export async function extractTablesFromOCR (ocrResult: any): Promise<ExtractedRo function isRelevantTable (table: OCRTable): boolean { const headers = table.cells.filter(c => c.rowIndex < 3).map(getCellText).join(' ').toLowerCase(); - return /\bno\b|번호/.test(headers) && /identification|식별|ident|id/.test(headers); + console.log(`🔍 Checking table relevance. Headers: "${headers}"`); + + // 기존 조건 + const hasNoColumn = /\bno\b|번호/.test(headers); + const hasIdentification = /identification|식별|ident|id/.test(headers); + + console.log(`📝 Has NO column: ${hasNoColumn}`); + console.log(`📝 Has Identification: ${hasIdentification}`); + + // 기본 조건 + if (hasNoColumn && hasIdentification) { + console.log(`✅ Table passes strict criteria`); + return true; + } + + // 완화된 조건들 + const relaxedConditions = [ + // 조건 1: 테이블에 여러 열이 있고 숫자나 식별자 패턴이 보이는 경우 + table.cells.length > 10 && /\d+/.test(headers), + + // 조건 2: joint, tag, weld 등 관련 키워드가 있는 경우 + /joint|tag|weld|type|date/.test(headers), + + // 조건 3: 식별번호 패턴이 보이는 경우 (하이픈이 포함된 문자열) + headers.includes('-') && headers.length > 20, + + // 조건 4: 한국어 관련 키워드 + /용접|조인트|태그/.test(headers) + ]; + + const passedConditions = relaxedConditions.filter(Boolean).length; + console.log(`📊 Relaxed conditions passed: ${passedConditions}/${relaxedConditions.length}`); + + if (passedConditions >= 1) { + console.log(`✅ Table passes relaxed criteria`); + return true; + } + + console.log(`❌ Table does not meet any criteria`); + return false; } - /* -------------------------------------------------------------------------- */ /* 표 해석 */ /* -------------------------------------------------------------------------- */ function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): ExtractedRow[] { + console.log(`🔧 Starting extractTableData for table ${imgIdx}-${tblIdx}`); + const grid = buildGrid(table); + console.log(`📊 Grid size: ${grid.length} rows x ${grid[0]?.length || 0} columns`); + const headerRowIdx = findHeaderRow(grid); - if (headerRowIdx === -1) return []; + console.log(`📍 Header row index: ${headerRowIdx}`); - const format = detectFormat(grid[headerRowIdx]); - const mapping = mapColumns(grid[headerRowIdx]); + if (headerRowIdx === -1) { + console.log(`❌ No header row found`); + return []; + } + + const format = detectFormat(grid[headerRowIdx]); + const mapping = mapColumns(grid[headerRowIdx]); + + console.log(`📋 Detected format: ${format}`); + console.log(`🗂️ Column mapping:`, mapping); const seen = new Set<string>(); const data: ExtractedRow[] = []; for (let r = headerRowIdx + 1; r < grid.length; r++) { const row = grid[r]; - if (isBlankRow(row)) continue; + + if (isBlankRow(row)) { + console.log(`⏭️ Row ${r}: blank, skipping`); + continue; + } + + console.log(`🔍 Processing row ${r}: [${row.join(' | ')}]`); const parsed = buildRow(row, format, mapping, tblIdx, r); - if (!parsed || !isValidRow(parsed)) continue; + if (!parsed) { + console.log(`❌ Row ${r}: failed to parse`); + continue; + } + + if (!isValidRow(parsed)) { + console.log(`❌ Row ${r}: invalid (no: "${parsed.no}", id: "${parsed.identificationNo}")`); + continue; + } const key = `${parsed.no}-${parsed.identificationNo}`; - if (seen.has(key)) continue; + if (seen.has(key)) { + console.log(`⚠️ Row ${r}: duplicate key "${key}", skipping`); + continue; + } + seen.add(key); - data.push(parsed); + console.log(`✅ Row ${r}: added (${JSON.stringify(parsed)})`); } + + console.log(`🎯 Table ${imgIdx}-${tblIdx}: extracted ${data.length} valid rows`); return data; } @@ -108,18 +178,39 @@ function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): Ext /* -------------------------------------------------------------------------- */ function buildGrid (table: OCRTable): string[][] { + console.log(`🔧 Building grid from ${table.cells.length} cells`); + const maxR = Math.max(...table.cells.map(c => c.rowIndex + c.rowSpan - 1)); const maxC = Math.max(...table.cells.map(c => c.columnIndex + c.columnSpan - 1)); + + console.log(`📊 Grid dimensions: ${maxR + 1} rows x ${maxC + 1} columns`); + const grid = Array.from({ length: maxR + 1 }, () => Array(maxC + 1).fill('')); - table.cells.forEach(cell => { + // 셀별 상세 정보 출력 + table.cells.forEach((cell, idx) => { const txt = getCellText(cell); + console.log(`📱 Cell ${idx}: (${cell.rowIndex},${cell.columnIndex}) span(${cell.rowSpan},${cell.columnSpan}) = "${txt}"`); + for (let r = cell.rowIndex; r < cell.rowIndex + cell.rowSpan; r++) { for (let c = cell.columnIndex; c < cell.columnIndex + cell.columnSpan; c++) { - grid[r][c] = grid[r][c] ? `${grid[r][c]} ${txt}` : txt; + const oldValue = grid[r][c]; + const newValue = oldValue ? `${oldValue} ${txt}` : txt; + grid[r][c] = newValue; + + if (oldValue) { + console.log(`🔄 Grid[${r}][${c}]: "${oldValue}" → "${newValue}"`); + } } } }); + + // 최종 그리드 출력 + console.log(`📋 Final grid:`); + grid.forEach((row, r) => { + console.log(` Row ${r}: [${row.map(cell => `"${cell}"`).join(', ')}]`); + }); + return grid; } @@ -128,13 +219,52 @@ function getCellText (cell: TableCell): string { } function findHeaderRow (grid: string[][]): number { + console.log(`🔍 Finding header row in grid with ${grid.length} rows`); + + for (let i = 0; i < Math.min(5, grid.length); i++) { + const rowText = grid[i].join(' ').toLowerCase(); + console.log(`📝 Row ${i}: "${rowText}"`); + + // 기존 엄격한 조건 + if (/\bno\b|번호/.test(rowText) && /identification|식별|ident/.test(rowText)) { + console.log(`✅ Row ${i}: Strict match`); + return i; + } + + // 완화된 조건들 + const relaxedMatches = [ + // 1. NO 컬럼 + 다른 관련 키워드 + (/\bno\b|번호/.test(rowText) && /joint|tag|type|weld|date/.test(rowText)), + + // 2. ID/식별 + 다른 관련 키워드 + (/identification|식별|ident|id/.test(rowText) && /joint|tag|no|type/.test(rowText)), + + // 3. 용접 관련 키워드가 여러 개 + (rowText.match(/joint|tag|type|weld|date|no|id|식별|번호|용접/g)?.length >= 3), + + // 4. 첫 번째 행이고 여러 단어가 있는 경우 + (i === 0 && rowText.split(/\s+/).filter(w => w.length > 1).length >= 3) + ]; + + if (relaxedMatches.some(Boolean)) { + console.log(`✅ Row ${i}: Relaxed match`); + return i; + } + + console.log(`❌ Row ${i}: No match`); + } + + // 최후의 수단: 첫 번째 비어있지 않은 행 for (let i = 0; i < Math.min(3, grid.length); i++) { - const t = grid[i].join(' ').toLowerCase(); - if (/\bno\b|번호/.test(t) && /identification|식별|ident/.test(t)) return i; + if (grid[i].some(cell => cell.trim().length > 0)) { + console.log(`⚠️ Using row ${i} as fallback header`); + return i; + } } + + console.log(`❌ No header row found`); return -1; } - /* -------------------------------------------------------------------------- */ /* Column Mapping */ /* -------------------------------------------------------------------------- */ @@ -146,19 +276,153 @@ function detectFormat (header: string[]): 'format1' | 'format2' { function mapColumns (header: string[]): ColumnMapping { const mp: ColumnMapping = { no: -1, identification: -1, tagNo: -1, jointNo: -1, jointType: -1, weldingDate: -1 }; + + console.log(`🗂️ Smart mapping columns from header: [${header.map(h => `"${h}"`).join(', ')}]`); + // === STEP 1: 기존 개별 컬럼 매핑 === header.forEach((h, i) => { - const t = h.toLowerCase(); - if (/^no\.?$/.test(t) && !/ident|tag|joint/.test(t)) mp.no = i; - else if (/identification|ident/.test(t)) mp.identification = i; - else if (/tag.*no/.test(t)) mp.tagNo = i; - else if (/joint.*no/.test(t)) mp.jointNo = i; - else if (/joint.*type/.test(t) || (/^type$/.test(t) && mp.jointType === -1)) mp.jointType = i; - else if (/welding|date/.test(t)) mp.weldingDate = i; + const t = h.toLowerCase().trim(); + console.log(`📋 Column ${i}: "${h}" → "${t}"`); + + if (mp.no === -1 && (/^no\.?$/i.test(t) || /^번호$/i.test(t) || /^순번$/i.test(t))) { + mp.no = i; + console.log(`✅ NO column (individual) mapped to index ${i}`); + } + + if (mp.identification === -1 && (/identification.*no/i.test(t) || /식별.*번호/i.test(t))) { + mp.identification = i; + console.log(`✅ Identification column (individual) mapped to index ${i}`); + } + + if (mp.tagNo === -1 && (/tag.*no/i.test(t) || /태그.*번호/i.test(t))) { + mp.tagNo = i; + console.log(`✅ Tag No column (individual) mapped to index ${i}`); + } + + if (mp.jointNo === -1 && (/joint.*no/i.test(t) || /조인트.*번호/i.test(t) || /oint.*no/i.test(t))) { + mp.jointNo = i; + console.log(`✅ Joint No column (individual) mapped to index ${i}`); + } + + if (mp.jointType === -1 && (/joint.*type/i.test(t) || /^type$/i.test(t) || /형태/i.test(t))) { + mp.jointType = i; + console.log(`✅ Joint Type column (individual) mapped to index ${i}`); + } + + if (mp.weldingDate === -1 && (/welding.*date/i.test(t) || /weld.*date/i.test(t) || /^date$/i.test(t) || /날짜/i.test(t))) { + mp.weldingDate = i; + console.log(`✅ Welding Date column (individual) mapped to index ${i}`); + } + }); + + // === STEP 2: 실용적 추론 === + console.log(`🤖 Starting practical column inference...`); + + // NO 컬럼이 매핑되지 않았다면, 첫 번째 컬럼을 NO로 추정 + if (mp.no === -1) { + mp.no = 0; + console.log(`🔮 NO column inferred as index 0 (first column)`); + } + + // Identification 컬럼 찾기 - "identification" 키워드가 포함된 컬럼 중에서 + if (mp.identification === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('identification') || text.includes('식별')) { + mp.identification = i; + console.log(`🆔 Identification column found at index ${i}`); + break; + } + } + } + + // Tag No 컬럼 찾기 - "tag" 키워드가 포함된 컬럼 중에서 + if (mp.tagNo === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('tag') && !text.includes('no')) { + mp.tagNo = i; + console.log(`🏷️ Tag column found at index ${i}`); + break; + } + } + } + + // Joint No 컬럼 찾기 + if (mp.jointNo === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('joint') || text.includes('oint')) { + mp.jointNo = i; + console.log(`🔗 Joint column found at index ${i}`); + break; + } + } + } + + // === STEP 3: 패턴 기반 추론 (마지막 수단) === + console.log(`🎯 Pattern-based fallback mapping...`); + + // 전체 헤더에서 실제 식별번호 패턴이 있는 컬럼 찾기 + if (mp.identification === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i]; + // 하이픈이 포함된 긴 문자열이 있는 컬럼 + if (text.includes('-') && text.length > 15) { + mp.identification = i; + console.log(`🆔 Identification inferred at index ${i} (contains ID pattern)`); + break; + } + } + } + + // 숫자 패턴이 있는 컬럼을 Tag No로 추정 + if (mp.tagNo === -1) { + for (let i = 1; i < header.length; i++) { // 첫 번째 컬럼 제외 + const text = header[i]; + // 7-8자리 숫자가 있는 컬럼 + if (/\d{7,8}/.test(text)) { + mp.tagNo = i; + console.log(`🏷️ Tag No inferred at index ${i} (contains number pattern)`); + break; + } + } + } + + // === STEP 4: 기본값 설정 === + console.log(`🔧 Setting default values for unmapped columns...`); + + // 여전히 매핑되지 않은 중요한 컬럼들에 대해 순서 기반 추정 + const essentialColumns = [ + { key: 'identification', currentValue: mp.identification, defaultIndex: 1 }, + { key: 'tagNo', currentValue: mp.tagNo, defaultIndex: 2 }, + { key: 'jointNo', currentValue: mp.jointNo, defaultIndex: 3 }, + { key: 'jointType', currentValue: mp.jointType, defaultIndex: 4 }, + { key: 'weldingDate', currentValue: mp.weldingDate, defaultIndex: Math.min(5, header.length - 1) } + ]; + + essentialColumns.forEach(col => { + if ((col.currentValue as number) === -1 && col.defaultIndex < header.length) { + (mp as any)[col.key] = col.defaultIndex; + console.log(`🔧 ${col.key} set to default index ${col.defaultIndex}`); + } }); + + console.log(`🎯 Final optimized column mapping:`, mp); + + // === STEP 5: 매핑 품질 검증 === + const mappedCount = Object.values(mp).filter(v => v !== -1).length; + const totalColumns = Object.keys(mp).length; + const mappingQuality = mappedCount / totalColumns; + + console.log(`📊 Mapping quality: ${mappedCount}/${totalColumns} (${(mappingQuality * 100).toFixed(1)}%)`); + + if (mappingQuality < 0.5) { + console.warn(`⚠️ Low mapping quality detected. Consider manual adjustment.`); + } + return mp; } - /* -------------------------------------------------------------------------- */ /* Row Extraction */ /* -------------------------------------------------------------------------- */ @@ -170,71 +434,351 @@ function buildRow ( tblIdx: number, rowIdx: number ): ExtractedRow | null { + console.log(`🔨 Building row from: [${row.map(r => `"${r}"`).join(', ')}]`); + console.log(`📋 Using mapping:`, mp); + console.log(`📄 Format: ${format}`); + const out: ExtractedRow = { - no: mp.no >= 0 ? clean(row[mp.no]) : '', + no: '', identificationNo: '', tagNo: '', jointNo: '', - jointType: mp.jointType >= 0 ? clean(row[mp.jointType]) : '', + jointType: '', weldingDate: '', confidence: 0, sourceTable: tblIdx, sourceRow: rowIdx, }; - if (mp.weldingDate >= 0) out.weldingDate = clean(row[mp.weldingDate]); - else { - const idx = row.findIndex(col => /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/.test(col)); - if (idx >= 0) out.weldingDate = clean(row[idx]); + // === STEP 1: 매핑된 컬럼에서 기본 추출 === + + // NO 컬럼 추출 + if (mp.no >= 0 && mp.no < row.length) { + const rawNo = clean(row[mp.no]); + // NO 필드에서 첫 번째 숫자 패턴 추출 + const noMatch = rawNo.match(/\b(\d{2,4})\b/); + out.no = noMatch ? noMatch[1] : rawNo; + console.log(`📍 NO from column ${mp.no}: "${out.no}" (raw: "${rawNo}")`); + } + + // Joint Type, Welding Date는 기존대로 + if (mp.jointType >= 0 && mp.jointType < row.length) { + out.jointType = clean(row[mp.jointType]); + console.log(`🔗 Joint Type from column ${mp.jointType}: "${out.jointType}"`); } + if (mp.weldingDate >= 0 && mp.weldingDate < row.length) { + out.weldingDate = clean(row[mp.weldingDate]); + console.log(`📅 Welding Date from column ${mp.weldingDate}: "${out.weldingDate}"`); + } + + // === STEP 2: Format별 데이터 추출 === + if (format === 'format2') { - if (mp.identification >= 0) out.identificationNo = clean(row[mp.identification]); - if (mp.jointNo >= 0) out.jointNo = clean(row[mp.jointNo]); - if (mp.tagNo >= 0) out.tagNo = clean(row[mp.tagNo]); + console.log(`📄 Processing Format 2 (separate columns)`); + + if (mp.identification >= 0 && mp.identification < row.length) { + out.identificationNo = clean(row[mp.identification]); + console.log(`🆔 Identification from column ${mp.identification}: "${out.identificationNo}"`); + } + + if (mp.jointNo >= 0 && mp.jointNo < row.length) { + out.jointNo = clean(row[mp.jointNo]); + console.log(`🔗 Joint No from column ${mp.jointNo}: "${out.jointNo}"`); + } + + if (mp.tagNo >= 0 && mp.tagNo < row.length) { + out.tagNo = clean(row[mp.tagNo]); + console.log(`🏷️ Tag No from column ${mp.tagNo}: "${out.tagNo}"`); + } } else { - const combined = mp.identification >= 0 ? row[mp.identification] : ''; - const parsed = parseIdentificationData(combined); + console.log(`📄 Processing Format 1 (combined identification column)`); + + let combinedText = ''; + + // 매핑된 identification 컬럼에서 텍스트 가져오기 + if (mp.identification >= 0 && mp.identification < row.length) { + combinedText = row[mp.identification]; + console.log(`🆔 Combined text from column ${mp.identification}: "${combinedText}"`); + } + + const parsed = parseIdentificationData(combinedText); out.identificationNo = parsed.identificationNo; - out.jointNo = parsed.jointNo; - out.tagNo = parsed.tagNo; + out.jointNo = parsed.jointNo; + out.tagNo = parsed.tagNo; + + console.log(`📊 Parsed from identification column:`, parsed); } + // === STEP 3: 적극적 패턴 매칭으로 누락된 필드 채우기 === + console.log(`🔍 Aggressive pattern matching for missing fields...`); + + const allText = row.join(' '); + console.log(`📝 Full row text: "${allText}"`); + + // NO 필드가 비어있다면 첫 번째 컬럼에서 숫자 패턴 찾기 + if (!out.no && row.length > 0) { + const firstCol = clean(row[0]); + const noPatterns = [ + /\b(\d{3})\b/g, // 3자리 숫자 + /\b(\d{2,4})\b/g, // 2-4자리 숫자 + /^(\d+)/ // 맨 앞 숫자 + ]; + + for (const pattern of noPatterns) { + const matches = firstCol.match(pattern); + if (matches && matches.length > 0) { + out.no = matches[0].replace(/\D/g, ''); // 숫자만 추출 + console.log(`📍 NO found via pattern in first column: "${out.no}"`); + break; + } + } + } + + // Identification No 패턴 찾기 (하이픈이 포함된 긴 문자열) + if (!out.identificationNo) { + const idPatterns = [ + /[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9\-]+/g, + /-\d+[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+/g, + /\b[A-Z]\d+[A-Z]-\d+-\d+-[A-Z]+-\d+-[A-Z0-9]+-[A-Z]-[A-Z0-9]+\b/g + ]; + + for (const pattern of idPatterns) { + const matches = allText.match(pattern); + if (matches && matches.length > 0) { + out.identificationNo = matches[0]; + console.log(`🆔 Identification found via pattern: "${out.identificationNo}"`); + break; + } + } + } + + // Tag No 패턴 찾기 (7-8자리 숫자) + if (!out.tagNo) { + const tagMatches = allText.match(/\b\d{7,8}\b/g); + if (tagMatches && tagMatches.length > 0) { + out.tagNo = tagMatches[0]; + console.log(`🏷️ Tag found via pattern: "${out.tagNo}"`); + } + } + + // Joint No 패턴 찾기 (짧은 영숫자 조합) + if (!out.jointNo) { + const jointPatterns = [ + /\b[A-Z]{2,4}\d*\b/g, // 대문자+숫자 조합 + /\b[A-Za-z0-9]{2,6}\b/g // 일반적인 짧은 조합 + ]; + + for (const pattern of jointPatterns) { + const matches = allText.match(pattern); + if (matches) { + const candidates = matches.filter(m => + m !== out.no && + m !== out.tagNo && + m !== out.identificationNo && + m.length >= 2 && m.length <= 6 && + !/^(no|tag|joint|type|date|welding|project|samsung|class)$/i.test(m) + ); + + if (candidates.length > 0) { + out.jointNo = candidates[0]; + console.log(`🔗 Joint found via pattern: "${out.jointNo}"`); + break; + } + } + } + } + + // Welding Date 패턴 찾기 + if (!out.weldingDate) { + const datePatterns = [ + /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/g, + /\d{4}\.\d{2}\.\d{2}/g + ]; + + for (const pattern of datePatterns) { + const matches = allText.match(pattern); + if (matches && matches.length > 0) { + out.weldingDate = matches[0]; + console.log(`📅 Date found via pattern: "${out.weldingDate}"`); + break; + } + } + } + + // === STEP 4: 품질 검증 및 후처리 === + + // 추출된 값들 정리 + Object.keys(out).forEach(key => { + const value = (out as any)[key]; + if (typeof value === 'string' && value) { + (out as any)[key] = value.replace(/^[^\w]+|[^\w]+$/g, '').trim(); + } + }); + out.confidence = scoreRow(out); + + console.log(`📊 Final extracted row:`, out); + console.log(`🎯 Row confidence: ${out.confidence}`); + + // 최소한의 데이터가 있는지 검증 + const hasAnyData = !!(out.no || out.identificationNo || out.tagNo || out.jointNo); + + if (!hasAnyData) { + console.log(`⚠️ No meaningful data extracted from row`); + return null; + } + return out; } - /* -------------------------------------------------------------------------- */ /* Format‑1 셀 파싱 */ /* -------------------------------------------------------------------------- */ function parseIdentificationData (txt: string): { identificationNo: string; jointNo: string; tagNo: string } { + console.log(`🔍 Parsing identification data from: "${txt}"`); + const cleaned = clean(txt); - if (!cleaned) return { identificationNo: '', jointNo: '', tagNo: '' }; + if (!cleaned) { + console.log(`❌ Empty input text`); + return { identificationNo: '', jointNo: '', tagNo: '' }; + } + console.log(`🧹 Cleaned text: "${cleaned}"`); + + const result = { identificationNo: '', jointNo: '', tagNo: '' }; + + // 1. Identification No 추출 (하이픈이 2개 이상 포함된 패턴) + const idPatterns = [ + /[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9\-]+/g, // 기본 패턴 + /-\d+[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+/g, // 앞에 하이픈이 있는 경우 + /\b[A-Za-z0-9]{2,}-[A-Za-z0-9]{2,}-[A-Za-z0-9]{2,}\b/g // 더 엄격한 패턴 + ]; + + for (const pattern of idPatterns) { + const matches = cleaned.match(pattern); + if (matches && matches.length > 0) { + // 가장 긴 매치를 선택 + result.identificationNo = matches.reduce((a, b) => a.length >= b.length ? a : b); + console.log(`🆔 Found identification: "${result.identificationNo}"`); + break; + } + } + + // 2. Tag No 추출 (7-8자리 숫자) + const tagPatterns = [ + /\btag[:\s]*(\d{7,8})\b/i, // "tag: 1234567" 형태 + /\b(\d{7,8})\b/g // 단순 7-8자리 숫자 + ]; + + for (const pattern of tagPatterns) { + const matches = cleaned.match(pattern); + if (matches) { + if (pattern.source.includes('tag')) { + result.tagNo = matches[1] || matches[0]; + } else { + // 모든 7-8자리 숫자를 찾아서 가장 적절한 것 선택 + const candidates = matches.filter(m => m && m.length >= 7 && m.length <= 8); + if (candidates.length > 0) { + result.tagNo = candidates[0]; + } + } + if (result.tagNo) { + console.log(`🏷️ Found tag: "${result.tagNo}"`); + break; + } + } + } + + // 3. Joint No 추출 (나머지 토큰 중에서) const tokens = cleaned.split(/\s+/).map(clean).filter(Boolean); - - // Identification 후보: 하이픈이 2개 이상 포함된 토큰 가운데 가장 긴 것 - const idCand = tokens.filter(t => t.split('-').length >= 3).sort((a, b) => b.length - a.length); - const identificationNo = idCand[0] || ''; - - const residual = tokens.filter(t => t !== identificationNo); - if (!residual.length) return { identificationNo, jointNo: '', tagNo: '' }; - - residual.sort((a, b) => a.length - b.length); - const jointNo = residual[0] || ''; - const tagNo = residual[residual.length - 1] || ''; - - return { identificationNo, jointNo, tagNo }; + console.log(`📝 All tokens: [${tokens.join(', ')}]`); + + // 이미 사용된 토큰들 제외 + const usedTokens = new Set([result.identificationNo, result.tagNo]); + const remainingTokens = tokens.filter(token => + !usedTokens.has(token) && + !result.identificationNo.includes(token) && + !result.tagNo.includes(token) && + token.length > 1 && + !/^(tag|joint|no|identification|식별|번호)$/i.test(token) + ); + + console.log(`🔄 Remaining tokens for joint: [${remainingTokens.join(', ')}]`); + + if (remainingTokens.length > 0) { + // 가장 짧고 알파벳+숫자 조합인 토큰을 Joint No로 선택 + const jointCandidates = remainingTokens + .filter(token => /^[A-Za-z0-9]+$/.test(token) && token.length >= 2 && token.length <= 8) + .sort((a, b) => a.length - b.length); + + if (jointCandidates.length > 0) { + result.jointNo = jointCandidates[0]; + console.log(`🔗 Found joint: "${result.jointNo}"`); + } else if (remainingTokens.length > 0) { + // 후보가 없으면 가장 짧은 토큰 사용 + result.jointNo = remainingTokens.reduce((a, b) => a.length <= b.length ? a : b); + console.log(`🔗 Found joint (fallback): "${result.jointNo}"`); + } + } + + // 4. 결과 검증 및 정리 + Object.keys(result).forEach(key => { + const value = (result as any)[key]; + if (value && typeof value === 'string') { + (result as any)[key] = value.replace(/^[^\w]+|[^\w]+$/g, ''); // 앞뒤 특수문자 제거 + } + }); + + console.log(`📊 Final parsed result:`, result); + return result; } - /* -------------------------------------------------------------------------- */ /* Helpers */ /* -------------------------------------------------------------------------- */ const clean = (s: string = '') => s.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); const isBlankRow = (row: string[]) => row.every(c => !clean(c)); -const isValidRow = (r: ExtractedRow) => !!(r.no || r.identificationNo); +function isValidRow (r: ExtractedRow): boolean { + console.log(`✅ Validating row: no="${r.no}", id="${r.identificationNo}", tag="${r.tagNo}", joint="${r.jointNo}"`); + + // Level 1: 기존 엄격한 조건 + if (r.no && r.no.trim() || r.identificationNo && r.identificationNo.trim()) { + console.log(`✅ Level 1 validation passed (has no or identification)`); + return true; + } + + // Level 2: 완화된 조건 - 주요 필드 중 2개 이상 + const mainFields = [ + r.no?.trim(), + r.identificationNo?.trim(), + r.tagNo?.trim(), + r.jointNo?.trim() + ].filter(Boolean); + + if (mainFields.length >= 2) { + console.log(`✅ Level 2 validation passed (${mainFields.length} main fields present)`); + return true; + } + + // Level 3: 더 관대한 조건 - 어떤 필드든 하나라도 의미있는 값 + const allFields = [ + r.no?.trim(), + r.identificationNo?.trim(), + r.tagNo?.trim(), + r.jointNo?.trim(), + r.jointType?.trim(), + r.weldingDate?.trim() + ].filter(field => field && field.length > 1); // 1글자 이상 + + if (allFields.length >= 1) { + console.log(`✅ Level 3 validation passed (${allFields.length} fields with meaningful content)`); + return true; + } + + console.log(`❌ Validation failed - no meaningful content found`); + return false; +} function scoreRow (r: ExtractedRow): number { const w: Record<keyof ExtractedRow, number> = { diff --git a/app/api/vendors/route.ts b/app/api/vendors/route.ts new file mode 100644 index 00000000..7c7dbb84 --- /dev/null +++ b/app/api/vendors/route.ts @@ -0,0 +1,248 @@ +// app/api/vendors/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { unstable_noStore } from 'next/cache' +import { revalidateTag } from 'next/cache' +import { randomUUID } from 'crypto' +import * as fs from 'fs/promises' +import * as path from 'path' +import { eq } from 'drizzle-orm' +import { PgTransaction } from 'drizzle-orm/pg-core' + +import db from '@/db/db' +import { users, vendors, vendorContacts, vendorAttachments } from '@/db/schema' +import { insertVendor } from '@/lib/vendors/repository' +import { getErrorMessage } from '@/lib/handle-error' + +// Types +interface CreateVendorData { + vendorName: string + vendorCode?: string + address?: string + country?: string + phone?: string + email: string + website?: string + status?: string + taxId: string + vendorTypeId: number + items?: string + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + corporateRegistrationNumber?: string +} + +interface ContactData { + contactName: string + contactPosition?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean +} + +// File attachment types +const FILE_TYPES = { + BUSINESS_REGISTRATION: 'BUSINESS_REGISTRATION', + ISO_CERTIFICATION: 'ISO_CERTIFICATION', + CREDIT_REPORT: 'CREDIT_REPORT', + BANK_ACCOUNT_COPY: 'BANK_ACCOUNT_COPY' +} as const + +type FileType = typeof FILE_TYPES[keyof typeof FILE_TYPES] + +async function storeVendorFiles( + tx: PgTransaction<any, any, any>, + vendorId: number, + files: File[], + attachmentType: FileType +) { + const vendorDir = path.join( + process.cwd(), + "public", + "vendors", + String(vendorId) + ) + await fs.mkdir(vendorDir, { recursive: true }) + + for (const file of files) { + // Convert file to buffer + const ab = await file.arrayBuffer() + const buffer = Buffer.from(ab) + + // Generate a unique filename + const uniqueName = `${randomUUID()}-${file.name}` + const relativePath = path.join("vendors", String(vendorId), uniqueName) + const absolutePath = path.join(process.cwd(), "public", relativePath) + + // Write to disk + await fs.writeFile(absolutePath, buffer) + + // Insert attachment record + await tx.insert(vendorAttachments).values({ + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + attachmentType, + }) + } +} + +export async function POST(request: NextRequest) { + unstable_noStore() + + try { + const formData = await request.formData() + + // Parse vendor data and contacts from JSON strings + const vendorDataString = formData.get('vendorData') as string + const contactsString = formData.get('contacts') as string + + if (!vendorDataString || !contactsString) { + return NextResponse.json( + { error: 'Missing vendor data or contacts' }, + { status: 400 } + ) + } + + const vendorData: CreateVendorData = JSON.parse(vendorDataString) + const contacts: ContactData[] = JSON.parse(contactsString) + + // Extract files by type + const businessRegistrationFiles = formData.getAll('businessRegistration') as File[] + const isoCertificationFiles = formData.getAll('isoCertification') as File[] + const creditReportFiles = formData.getAll('creditReport') as File[] + const bankAccountFiles = formData.getAll('bankAccount') as File[] + + // Validate required files + if (businessRegistrationFiles.length === 0) { + return NextResponse.json( + { error: '사업자등록증을 업로드해주세요.' }, + { status: 400 } + ) + } + + if (isoCertificationFiles.length === 0) { + return NextResponse.json( + { error: 'ISO 인증서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (creditReportFiles.length === 0) { + return NextResponse.json( + { error: '신용평가보고서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (vendorData.country !== "KR" && bankAccountFiles.length === 0) { + return NextResponse.json( + { error: '대금지급 통장사본을 업로드해주세요.' }, + { status: 400 } + ) + } + + // Check for existing email + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, vendorData.email)) + .limit(1) + + if (existingUser.length > 0) { + return NextResponse.json( + { + error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` + }, + { status: 400 } + ) + } + + // Check for existing taxId + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendorData.taxId)) + .limit(1) + + if (existingVendor.length > 0) { + return NextResponse.json( + { + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` + }, + { status: 400 } + ) + } + + // Create vendor and handle files in transaction + await db.transaction(async (tx) => { + // Insert the vendor + const [newVendor] = await insertVendor(tx, { + vendorName: vendorData.vendorName, + vendorCode: vendorData.vendorCode || null, + address: vendorData.address || null, + country: vendorData.country || null, + phone: vendorData.phone || null, + email: vendorData.email, + website: vendorData.website || null, + status: vendorData.status ?? "PENDING_REVIEW", + taxId: vendorData.taxId, + vendorTypeId: vendorData.vendorTypeId, + items: vendorData.items || null, + + // Representative info + representativeName: vendorData.representativeName || null, + representativeBirth: vendorData.representativeBirth || null, + representativeEmail: vendorData.representativeEmail || null, + representativePhone: vendorData.representativePhone || null, + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, + representativeWorkExpirence: vendorData.representativeWorkExpirence || false, + + }) + + // Store files by type + if (businessRegistrationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, businessRegistrationFiles, FILE_TYPES.BUSINESS_REGISTRATION) + } + + if (isoCertificationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, isoCertificationFiles, FILE_TYPES.ISO_CERTIFICATION) + } + + if (creditReportFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, creditReportFiles, FILE_TYPES.CREDIT_REPORT) + } + + if (bankAccountFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, bankAccountFiles, FILE_TYPES.BANK_ACCOUNT_COPY) + } + + // Insert contacts + for (const contact of contacts) { + await tx.insert(vendorContacts).values({ + vendorId: newVendor.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary ?? false, + }) + } + }) + + revalidateTag("vendors") + + return NextResponse.json( + { message: '벤더 등록이 완료되었습니다.' }, + { status: 201 } + ) + + } catch (error) { + console.error('Vendor creation error:', error) + return NextResponse.json( + { error: getErrorMessage(error) }, + { status: 500 } + ) + } +}
\ No newline at end of file |
