기능구현중

This commit is contained in:
2026-01-11 17:01:01 +09:00
parent 375d5bf91a
commit 954ba21211
148 changed files with 2276 additions and 0 deletions

69
server/utils/db.ts Normal file
View File

@@ -0,0 +1,69 @@
import pg from 'pg'
const { Pool } = pg
let pool: pg.Pool | null = null
/**
* PostgreSQL 연결 풀 가져오기
*/
export function getPool(): pg.Pool {
if (!pool) {
const poolConfig = {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'weeklyreport',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}
console.log(`[DB] Connecting to ${poolConfig.host}:${poolConfig.port}/${poolConfig.database}`)
pool = new Pool(poolConfig)
pool.on('error', (err) => {
console.error('[DB] Unexpected pool error:', err)
})
console.log('[DB] PostgreSQL pool created')
}
return pool
}
/**
* 쿼리 실행
*/
export async function query<T = any>(sql: string, params?: any[]): Promise<T[]> {
const pool = getPool()
const result = await pool.query(sql, params)
return result.rows as T[]
}
/**
* 단일 행 조회
*/
export async function queryOne<T = any>(sql: string, params?: any[]): Promise<T | null> {
const rows = await query<T>(sql, params)
return rows[0] || null
}
/**
* INSERT/UPDATE/DELETE 실행
*/
export async function execute(sql: string, params?: any[]): Promise<number> {
const pool = getPool()
const result = await pool.query(sql, params)
return result.rowCount || 0
}
/**
* INSERT 후 반환
*/
export async function insertReturning<T = any>(sql: string, params?: any[]): Promise<T | null> {
const pool = getPool()
const result = await pool.query(sql, params)
return result.rows[0] || null
}

92
server/utils/email.ts Normal file
View File

@@ -0,0 +1,92 @@
import * as nodemailer from 'nodemailer'
/**
* 이메일 발송 유틸리티
*/
let transporter: nodemailer.Transporter | null = null
function getTransporter() {
if (transporter) return transporter
const host = process.env.SMTP_HOST || 'smtp.gmail.com'
const port = parseInt(process.env.SMTP_PORT || '587')
const user = process.env.SMTP_USER || ''
const pass = process.env.SMTP_PASS || ''
if (!user || !pass) {
console.warn('SMTP credentials not configured')
return null
}
transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass }
})
return transporter
}
interface EmailOptions {
to: string
subject: string
html: string
text?: string
}
export async function sendEmail(options: EmailOptions): Promise<boolean> {
const t = getTransporter()
if (!t) {
console.error('Email transporter not configured')
return false
}
const from = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@example.com'
try {
await t.sendMail({
from,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text
})
return true
} catch (e) {
console.error('Email send error:', e)
return false
}
}
/**
* 임시 비밀번호 이메일 발송
*/
export async function sendTempPasswordEmail(
toEmail: string,
employeeName: string,
tempPassword: string
): Promise<boolean> {
const subject = '[주간업무보고] 임시 비밀번호 발급'
const html = `
<div style="font-family: 'Malgun Gothic', sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">임시 비밀번호 발급</h2>
<p>안녕하세요, <strong>${employeeName}</strong>님.</p>
<p>요청하신 임시 비밀번호가 발급되었습니다.</p>
<div style="background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-size: 18px;"><strong>임시 비밀번호:</strong></p>
<p style="margin: 10px 0 0; font-size: 24px; font-family: monospace; color: #007bff;">${tempPassword}</p>
</div>
<p style="color: #666;">
※ 로그인 후 반드시 비밀번호를 변경해 주세요.<br/>
※ 본인이 요청하지 않은 경우, 이 메일을 무시하세요.
</p>
<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />
<p style="color: #999; font-size: 12px;">주간업무보고 시스템</p>
</div>
`
const text = `임시 비밀번호: ${tempPassword}\n\n로그인 후 비밀번호를 변경해 주세요.`
return sendEmail({ to: toEmail, subject, html, text })
}

236
server/utils/git-sync.ts Normal file
View File

@@ -0,0 +1,236 @@
import { query, execute, queryOne } from './db'
import { existsSync, mkdirSync, rmSync } from 'fs'
import { join } from 'path'
import { execSync, exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
// 임시 저장소 디렉토리
const REPO_TEMP_DIR = process.env.REPO_TEMP_DIR || join(process.cwd(), '.tmp', 'repos')
interface CommitInfo {
hash: string
author: string
email: string
date: string
message: string
filesChanged?: number
insertions?: number
deletions?: number
}
/**
* 저장소 정보 조회
*/
async function getRepositoryInfo(repoId: number) {
return await queryOne(`
SELECT
r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url,
r.default_branch, r.last_sync_at,
s.server_id, s.server_type, s.server_url, s.server_name,
s.auth_type, s.auth_username, s.auth_credential
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1 AND r.is_active = true
`, [repoId])
}
/**
* Git 저장소 URL 생성
*/
function buildGitUrl(serverUrl: string, repoPath: string, authUsername?: string, authCredential?: string): string {
// 이미 전체 URL인 경우
if (repoPath.startsWith('http://') || repoPath.startsWith('https://') || repoPath.startsWith('git@')) {
return repoPath
}
// 서버 URL + 경로 조합
let baseUrl = serverUrl.replace(/\/$/, '')
let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}`
// HTTPS 인증 추가
if (authUsername && authCredential && baseUrl.startsWith('https://')) {
const urlObj = new URL(baseUrl)
urlObj.username = encodeURIComponent(authUsername)
urlObj.password = encodeURIComponent(authCredential)
baseUrl = urlObj.toString().replace(/\/$/, '')
}
return `${baseUrl}${path}`
}
/**
* Git 커밋 로그 파싱
*/
function parseGitLog(output: string): CommitInfo[] {
const commits: CommitInfo[] = []
const lines = output.trim().split('\n')
for (const line of lines) {
if (!line.trim()) continue
// 포맷: hash|author|email|date|message
const parts = line.split('|')
if (parts.length >= 5) {
commits.push({
hash: parts[0],
author: parts[1],
email: parts[2],
date: parts[3],
message: parts.slice(4).join('|') // 메시지에 | 있을 수 있음
})
}
}
return commits
}
/**
* VCS 계정으로 사용자 매칭
*/
async function matchAuthor(serverId: number, authorName: string, authorEmail: string): Promise<number | null> {
// 이메일로 먼저 매칭
let matched = await queryOne(`
SELECT employee_id FROM wr_employee_vcs_account
WHERE server_id = $1 AND (vcs_email = $2 OR vcs_username = $3)
`, [serverId, authorEmail, authorName])
if (matched) {
return matched.employee_id
}
// VCS 계정에 없으면 직원 이메일로 매칭 시도
matched = await queryOne(`
SELECT employee_id FROM wr_employee_info
WHERE email = $1 AND is_active = true
`, [authorEmail])
return matched?.employee_id || null
}
/**
* Git 저장소 동기화
*/
export async function syncGitRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> {
const repo = await getRepositoryInfo(repoId)
if (!repo) {
return { success: false, message: '저장소를 찾을 수 없습니다.' }
}
if (repo.server_type !== 'GIT') {
return { success: false, message: 'Git 저장소가 아닙니다.' }
}
// 임시 디렉토리 생성
if (!existsSync(REPO_TEMP_DIR)) {
mkdirSync(REPO_TEMP_DIR, { recursive: true })
}
const localPath = join(REPO_TEMP_DIR, `repo_${repoId}`)
const gitUrl = buildGitUrl(repo.server_url, repo.repo_path, repo.auth_username, repo.auth_credential)
const branch = repo.default_branch || 'main'
try {
// Clone 또는 Pull
if (existsSync(localPath)) {
// 기존 저장소 업데이트
await execAsync(`cd "${localPath}" && git fetch origin && git reset --hard origin/${branch}`)
} else {
// 새로 클론 (shallow clone으로 최근 커밋만)
await execAsync(`git clone --depth 100 --single-branch --branch ${branch} "${gitUrl}" "${localPath}"`)
}
// 마지막 동기화 이후 커밋 조회
let sinceOption = ''
if (repo.last_sync_at) {
const sinceDate = new Date(repo.last_sync_at)
sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터 (중복 방지용 UPSERT 사용)
sinceOption = `--since="${sinceDate.toISOString()}"`
} else {
sinceOption = '--since="30 days ago"' // 최초 동기화: 최근 30일
}
// 커밋 로그 조회
const logFormat = '%H|%an|%ae|%aI|%s'
const { stdout } = await execAsync(
`cd "${localPath}" && git log ${sinceOption} --format="${logFormat}" --no-merges`,
{ maxBuffer: 10 * 1024 * 1024 } // 10MB
)
const commits = parseGitLog(stdout)
let insertedCount = 0
// 커밋 저장
for (const commit of commits) {
const employeeId = await matchAuthor(repo.server_id, commit.author, commit.email)
// UPSERT (중복 무시)
const result = await execute(`
INSERT INTO wr_commit_log (
repo_id, commit_hash, commit_message, commit_author, commit_email,
commit_date, employee_id, synced_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
ON CONFLICT (repo_id, commit_hash) DO NOTHING
`, [
repoId,
commit.hash,
commit.message,
commit.author,
commit.email,
commit.date,
employeeId
])
if (result.rowCount && result.rowCount > 0) {
insertedCount++
}
}
// 동기화 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, `${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`])
return {
success: true,
message: `동기화 완료: ${commits.length}개 커밋 조회, ${insertedCount}개 신규 저장`,
commitCount: insertedCount
}
} catch (error: any) {
console.error('Git sync error:', error)
// 실패 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_status = 'FAILED', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, error.message?.substring(0, 500)])
return { success: false, message: error.message || '동기화 실패' }
}
}
/**
* 프로젝트의 모든 Git 저장소 동기화
*/
export async function syncProjectGitRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> {
const repos = await query(`
SELECT r.repo_id
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'GIT'
`, [projectId])
const results = []
for (const repo of repos) {
const result = await syncGitRepository(repo.repo_id)
results.push({ repoId: repo.repo_id, ...result })
}
return { success: results.every(r => r.success), results }
}

View File

@@ -0,0 +1,66 @@
import { query, execute } from './db'
const config = useRuntimeConfig()
/**
* Google Access Token 갱신
*/
export async function refreshGoogleToken(employeeId: number): Promise<string | null> {
// 현재 토큰 정보 조회
const rows = await query<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at
FROM wr_employee_info
WHERE employee_id = $1
`, [employeeId])
const employee = rows[0]
if (!employee?.google_refresh_token) {
return null
}
// 토큰이 아직 유효하면 그대로 반환 (5분 여유)
const expiresAt = new Date(employee.google_token_expires_at)
if (expiresAt.getTime() > Date.now() + 5 * 60 * 1000) {
return employee.google_access_token
}
// 토큰 갱신
try {
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: employee.google_refresh_token,
grant_type: 'refresh_token'
})
})
const data = await res.json()
if (!data.access_token) {
console.error('Token refresh failed:', data)
return null
}
// 새 토큰 저장
await execute(`
UPDATE wr_employee_info SET
google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${data.expires_in} seconds'
WHERE employee_id = $2
`, [data.access_token, employeeId])
return data.access_token
} catch (e) {
console.error('Token refresh error:', e)
return null
}
}
/**
* 유효한 Google Access Token 조회 (자동 갱신)
*/
export async function getValidGoogleToken(employeeId: number): Promise<string | null> {
return refreshGoogleToken(employeeId)
}

33
server/utils/ip.ts Normal file
View File

@@ -0,0 +1,33 @@
import type { H3Event } from 'h3'
/**
* 클라이언트 IP 주소 가져오기
*/
export function getClientIp(event: H3Event): string {
// 프록시/로드밸런서 뒤에 있을 경우
const xForwardedFor = getHeader(event, 'x-forwarded-for')
if (xForwardedFor) {
return xForwardedFor.split(',')[0].trim()
}
const xRealIp = getHeader(event, 'x-real-ip')
if (xRealIp) {
return xRealIp
}
// 직접 연결
const remoteAddress = event.node.req.socket?.remoteAddress
if (remoteAddress) {
// IPv6 localhost를 IPv4로 변환
if (remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1') {
return '127.0.0.1'
}
// IPv6 매핑된 IPv4 주소 처리
if (remoteAddress.startsWith('::ffff:')) {
return remoteAddress.substring(7)
}
return remoteAddress
}
return 'unknown'
}

243
server/utils/openai.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* OpenAI API 유틸리티
*/
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
// 모델별 파라미터 설정
const MODEL_CONFIG: Record<string, { maxTokensParam: string; defaultMaxTokens: number }> = {
// 최신 모델 (max_completion_tokens 사용)
'gpt-5.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'gpt-5': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'gpt-4.1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'gpt-4.1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'gpt-4.1-nano': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'o1': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'o1-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'o1-pro': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
'o3-mini': { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 },
// 이전 모델 (max_tokens 사용)
'gpt-4o': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
'gpt-4o-mini': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
'gpt-4-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
'gpt-4': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
'gpt-3.5-turbo': { maxTokensParam: 'max_tokens', defaultMaxTokens: 4096 },
}
// 기본 모델 설정
const DEFAULT_MODEL = 'gpt-5.1'
function getModelConfig(model: string) {
if (MODEL_CONFIG[model]) {
return MODEL_CONFIG[model]
}
for (const key of Object.keys(MODEL_CONFIG)) {
if (model.startsWith(key)) {
return MODEL_CONFIG[key]
}
}
return { maxTokensParam: 'max_completion_tokens', defaultMaxTokens: 4096 }
}
interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
}
interface OpenAIResponse {
choices: {
message: {
content: string
}
}[]
}
export async function callOpenAI(
messages: ChatMessage[],
jsonMode = true,
model = DEFAULT_MODEL
): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey || apiKey === 'your-openai-api-key-here') {
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
}
const config = getModelConfig(model)
const requestBody: any = {
model,
messages,
temperature: 0.1,
[config.maxTokensParam]: config.defaultMaxTokens,
}
if (jsonMode) {
requestBody.response_format = { type: 'json_object' }
}
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const error = await response.text()
throw new Error(`OpenAI API 오류: ${response.status} - ${error}`)
}
const data = await response.json() as OpenAIResponse
return data.choices[0].message.content
}
/**
* 이미지 분석용 OpenAI 호출 (Vision)
*/
export async function callOpenAIVision(
systemPrompt: string,
imageBase64List: string[],
model = DEFAULT_MODEL
): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey || apiKey === 'your-openai-api-key-here') {
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
}
const config = getModelConfig(model)
const imageContents = imageBase64List.map(base64 => ({
type: 'image_url' as const,
image_url: {
url: base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`
}
}))
const requestBody: any = {
model,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: '이 이미지들에서 주간보고 내용을 추출해주세요.' },
...imageContents
]
}
],
temperature: 0.1,
[config.maxTokensParam]: config.defaultMaxTokens,
response_format: { type: 'json_object' }
}
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const error = await response.text()
throw new Error(`OpenAI Vision API 오류: ${response.status} - ${error}`)
}
const data = await response.json() as OpenAIResponse
return data.choices[0].message.content
}
/**
* 주간보고 분석 시스템 프롬프트 (Task 기반)
*/
export const REPORT_PARSE_SYSTEM_PROMPT = `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
## 핵심 원칙
- **원문의 내용을 그대로 유지하세요!**
- **Task는 적당히 묶어서 정리하세요. 너무 세분화하지 마세요!**
- 하나의 Task에 여러 줄이 들어갈 수 있습니다.
## Task 분리 규칙 (중요!)
❌ 잘못된 예 (너무 세분화):
- Task 1: "API 개발"
- Task 2: "API 테스트"
✅ 올바른 예 (적절히 묶기):
- Task 1: "API 개발 및 테스트 완료"
❌ 잘못된 예 (프로젝트명 반복):
- "PIMS 고도화 - 사용자 인증 개발"
✅ 올바른 예 (프로젝트명 제외):
- "사용자 인증 개발"
## 완료여부(isCompleted) 판단 규칙 ★중요★
금주 실적(workTasks)의 완료여부를 판단합니다:
- 기본값: true (완료)
- false (진행중): 차주 계획(planTasks)에 비슷한/연관된 작업이 있는 경우
예시:
- 실적: "로그인 API 개발" + 계획: "로그인 API 테스트" → isCompleted: false (연관 작업 있음)
- 실적: "DB 백업 완료" + 계획에 관련 없음 → isCompleted: true
## 수행시간 예측 기준
- **0시간**: "없음", "특이사항 없음", "해당없음", "한 게 없다", "작업 없음" 등 실제 작업이 없는 경우
- 단순 작업: 2~4시간
- 일반 작업: 8시간 (1일)
- 복잡한 작업: 16~24시간 (2~3일)
## JSON 출력 형식
{
"reportYear": 2025,
"reportWeek": 2,
"weekStartDate": "2025-01-06",
"weekEndDate": "2025-01-12",
"reports": [
{
"employeeName": "홍길동",
"employeeEmail": "hong@example.com",
"projects": [
{
"projectName": "PIMS 고도화",
"workTasks": [
{ "description": "사용자 인증 모듈 개발", "hours": 16, "isCompleted": false },
{ "description": "DB 백업 스크립트 작성", "hours": 4, "isCompleted": true }
],
"planTasks": [
{ "description": "사용자 인증 테스트 및 배포", "hours": 8 }
]
}
],
"issueDescription": "개발서버 메모리 부족",
"vacationDescription": null,
"remarkDescription": null
}
]
}
## 주의사항
- Task description에 프로젝트명을 포함하지 마세요
- 비슷한 작업은 하나의 Task로 묶으세요
- 한 Task 내 여러 항목은 \\n으로 줄바꿈
- 이메일이 없으면 employeeEmail은 null`
/**
* 주간보고 텍스트 분석 프롬프트
*/
export function buildParseReportPrompt(rawText: string): ChatMessage[] {
return [
{ role: 'system', content: REPORT_PARSE_SYSTEM_PROMPT },
{ role: 'user', content: rawText }
]
}

34
server/utils/password.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as crypto from 'crypto'
const SALT_ROUNDS = 10
/**
* 비밀번호 해시 생성 (bcrypt 대신 Node.js 내장 crypto 사용)
*/
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.randomBytes(16).toString('hex')
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex')
return `${salt}:${hash}`
}
/**
* 비밀번호 검증
*/
export async function verifyPassword(password: string, storedHash: string): Promise<boolean> {
const [salt, hash] = storedHash.split(':')
if (!salt || !hash) return false
const verifyHash = crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex')
return hash === verifyHash
}
/**
* 임시 비밀번호 생성
*/
export function generateTempPassword(length: number = 12): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%'
let password = ''
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length))
}
return password
}

250
server/utils/session.ts Normal file
View File

@@ -0,0 +1,250 @@
import crypto from 'crypto'
import { query, queryOne, execute, insertReturning } from './db'
// 세션 설정
const SESSION_TIMEOUT_MINUTES = 10 // 10분 타임아웃
const SESSION_COOKIE_NAME = 'session_token'
const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 쿠키는 7일 (세션 만료와 별개)
interface Session {
sessionId: string
employeeId: number
loginHistoryId: number | null
createdAt: Date
lastAccessAt: Date
expiresAt: Date
loginIp: string | null
userAgent: string | null
}
/**
* 랜덤 세션 토큰 생성 (Spring Session과 유사)
*/
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex') // 64자 hex 문자열
}
/**
* 세션 생성
*/
export async function createSession(
employeeId: number,
loginHistoryId: number,
loginIp: string | null,
userAgent: string | null
): Promise<string> {
const sessionId = generateSessionToken()
const expiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
await execute(`
INSERT INTO wr_session (session_id, employee_id, login_history_id, expires_at, login_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
`, [sessionId, employeeId, loginHistoryId, expiresAt, loginIp, userAgent])
return sessionId
}
/**
* 세션 조회 (유효한 세션만)
*/
export async function getDbSession(sessionId: string): Promise<Session | null> {
if (!sessionId) return null
const row = await queryOne<any>(`
SELECT
session_id,
employee_id,
login_history_id,
created_at,
last_access_at,
expires_at,
login_ip,
user_agent
FROM wr_session
WHERE session_id = $1 AND expires_at > NOW()
`, [sessionId])
if (!row) return null
return {
sessionId: row.session_id,
employeeId: row.employee_id,
loginHistoryId: row.login_history_id,
createdAt: row.created_at,
lastAccessAt: row.last_access_at,
expiresAt: row.expires_at,
loginIp: row.login_ip,
userAgent: row.user_agent
}
}
/**
* 세션 갱신 (Sliding Expiration)
* - 마지막 접근 시간 업데이트
* - 만료 시간 연장
*/
export async function refreshSession(sessionId: string): Promise<boolean> {
const newExpiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
const result = await execute(`
UPDATE wr_session
SET last_access_at = NOW(), expires_at = $1
WHERE session_id = $2 AND expires_at > NOW()
`, [newExpiresAt, sessionId])
return result.rowCount > 0
}
/**
* 세션 삭제 (로그아웃)
*/
export async function deleteSession(sessionId: string): Promise<boolean> {
const result = await execute(`
DELETE FROM wr_session WHERE session_id = $1
`, [sessionId])
return result.rowCount > 0
}
/**
* 사용자의 모든 세션 삭제 (모든 기기에서 로그아웃)
*/
export async function deleteAllUserSessions(employeeId: number): Promise<number> {
const result = await execute(`
DELETE FROM wr_session WHERE employee_id = $1
`, [employeeId])
return result.rowCount
}
/**
* 만료된 세션 정리 (배치용)
*/
export async function cleanupExpiredSessions(): Promise<number> {
const result = await execute(`
DELETE FROM wr_session WHERE expires_at < NOW()
`)
return result.rowCount
}
/**
* 세션 쿠키 설정
*/
export function setSessionCookie(event: any, sessionId: string) {
setCookie(event, SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_COOKIE_MAX_AGE,
path: '/'
})
}
/**
* 세션 쿠키 삭제
*/
export function deleteSessionCookie(event: any) {
deleteCookie(event, SESSION_COOKIE_NAME)
}
/**
* 세션 쿠키에서 세션 ID 가져오기
*/
export function getSessionIdFromCookie(event: any): string | null {
return getCookie(event, SESSION_COOKIE_NAME) || null
}
// 설정값 export
export { SESSION_TIMEOUT_MINUTES, SESSION_COOKIE_NAME }
/**
* 인증된 사용자 ID 가져오기 (다른 API에서 사용)
* - 세션이 없거나 만료되면 null 반환
* - 세션이 유효하면 자동 갱신
* - [호환성] 기존 user_id 쿠키도 지원 (마이그레이션 기간)
*/
export async function getAuthenticatedUserId(event: any): Promise<number | null> {
// 1. 새로운 세션 토큰 확인
const sessionId = getSessionIdFromCookie(event)
if (sessionId) {
const session = await getDbSession(sessionId)
if (session) {
await refreshSession(sessionId)
return session.employeeId
}
// 세션 만료 → 쿠키 삭제
deleteSessionCookie(event)
}
// 2. [호환성] 기존 user_id 쿠키 확인 (마이그레이션 기간)
const legacyUserId = getCookie(event, 'user_id')
if (legacyUserId) {
return parseInt(legacyUserId)
}
return null
}
/**
* 인증 필수 API용 - 미인증시 에러 throw
*/
export async function requireAuth(event: any): Promise<number> {
const userId = await getAuthenticatedUserId(event)
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
return userId
}
/**
* 사용자 권한 조회
*/
export async function getUserRoles(employeeId: number): Promise<string[]> {
const rows = await query<any>(`
SELECT r.role_code
FROM wr_employee_role er
JOIN wr_role r ON er.role_id = r.role_id
WHERE er.employee_id = $1 AND r.is_active = true
`, [employeeId])
return rows.map(r => r.role_code)
}
/**
* 특정 권한 보유 여부 확인
*/
export async function hasRole(employeeId: number, roleCode: string): Promise<boolean> {
const roles = await getUserRoles(employeeId)
return roles.includes(roleCode)
}
/**
* 관리자 권한 필수 API용 - ROLE_ADMIN 없으면 에러 throw
*/
export async function requireAdmin(event: any): Promise<number> {
const userId = await requireAuth(event)
const isAdmin = await hasRole(userId, 'ROLE_ADMIN')
if (!isAdmin) {
throw createError({ statusCode: 403, message: '관리자 권한이 필요합니다.' })
}
return userId
}
/**
* 매니저 이상 권한 필수 API용 - ROLE_MANAGER 또는 ROLE_ADMIN 없으면 에러 throw
*/
export async function requireManager(event: any): Promise<number> {
const userId = await requireAuth(event)
const roles = await getUserRoles(userId)
const hasManagerRole = roles.includes('ROLE_MANAGER') || roles.includes('ROLE_ADMIN')
if (!hasManagerRole) {
throw createError({ statusCode: 403, message: '매니저 이상 권한이 필요합니다.' })
}
return userId
}

217
server/utils/svn-sync.ts Normal file
View File

@@ -0,0 +1,217 @@
import { query, execute, queryOne } from './db'
import { execSync, exec } from 'child_process'
import { promisify } from 'util'
const execAsync = promisify(exec)
interface SvnLogEntry {
revision: string
author: string
date: string
message: string
}
/**
* 저장소 정보 조회
*/
async function getRepositoryInfo(repoId: number) {
return await queryOne(`
SELECT
r.repo_id, r.project_id, r.repo_name, r.repo_path, r.repo_url,
r.default_branch, r.last_sync_at,
s.server_id, s.server_type, s.server_url, s.server_name,
s.auth_type, s.auth_username, s.auth_credential
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.repo_id = $1 AND r.is_active = true
`, [repoId])
}
/**
* SVN URL 생성
*/
function buildSvnUrl(serverUrl: string, repoPath: string): string {
// 이미 전체 URL인 경우
if (repoPath.startsWith('svn://') || repoPath.startsWith('http://') || repoPath.startsWith('https://')) {
return repoPath
}
// 서버 URL + 경로 조합
let baseUrl = serverUrl.replace(/\/$/, '')
let path = repoPath.startsWith('/') ? repoPath : `/${repoPath}`
return `${baseUrl}${path}`
}
/**
* SVN 로그 XML 파싱
*/
function parseSvnLogXml(xmlContent: string): SvnLogEntry[] {
const entries: SvnLogEntry[] = []
// 간단한 XML 파싱 (정규식 사용)
const logEntryRegex = /<logentry\s+revision="(\d+)">([\s\S]*?)<\/logentry>/g
const authorRegex = /<author>(.*?)<\/author>/
const dateRegex = /<date>(.*?)<\/date>/
const msgRegex = /<msg>([\s\S]*?)<\/msg>/
let match
while ((match = logEntryRegex.exec(xmlContent)) !== null) {
const revision = match[1]
const content = match[2]
const authorMatch = content.match(authorRegex)
const dateMatch = content.match(dateRegex)
const msgMatch = content.match(msgRegex)
entries.push({
revision,
author: authorMatch ? authorMatch[1] : '',
date: dateMatch ? dateMatch[1] : '',
message: msgMatch ? msgMatch[1].trim() : ''
})
}
return entries
}
/**
* VCS 계정으로 사용자 매칭
*/
async function matchAuthor(serverId: number, authorName: string): Promise<number | null> {
// SVN 사용자명으로 매칭
let matched = await queryOne(`
SELECT employee_id FROM wr_employee_vcs_account
WHERE server_id = $1 AND vcs_username = $2
`, [serverId, authorName])
if (matched) {
return matched.employee_id
}
// VCS 계정에 없으면 직원 이름으로 매칭 시도
matched = await queryOne(`
SELECT employee_id FROM wr_employee_info
WHERE (employee_name = $1 OR display_name = $1) AND is_active = true
`, [authorName])
return matched?.employee_id || null
}
/**
* SVN 저장소 동기화
*/
export async function syncSvnRepository(repoId: number): Promise<{ success: boolean; message: string; commitCount?: number }> {
const repo = await getRepositoryInfo(repoId)
if (!repo) {
return { success: false, message: '저장소를 찾을 수 없습니다.' }
}
if (repo.server_type !== 'SVN') {
return { success: false, message: 'SVN 저장소가 아닙니다.' }
}
const svnUrl = buildSvnUrl(repo.server_url, repo.repo_path)
// SVN 명령어 구성
let command = `svn log "${svnUrl}" --xml`
// 인증 정보 추가
if (repo.auth_username && repo.auth_credential) {
command += ` --username "${repo.auth_username}" --password "${repo.auth_credential}"`
}
// 기간 제한
if (repo.last_sync_at) {
const sinceDate = new Date(repo.last_sync_at)
sinceDate.setDate(sinceDate.getDate() - 1) // 1일 전부터
command += ` -r {${sinceDate.toISOString()}}:HEAD`
} else {
// 최초 동기화: 최근 100개 또는 30일
command += ' -l 100'
}
// 비대화형 모드
command += ' --non-interactive --trust-server-cert-failures=unknown-ca,cn-mismatch,expired,not-yet-valid,other'
try {
const { stdout, stderr } = await execAsync(command, {
maxBuffer: 10 * 1024 * 1024, // 10MB
timeout: 60000 // 60초 타임아웃
})
const entries = parseSvnLogXml(stdout)
let insertedCount = 0
// 커밋 저장
for (const entry of entries) {
const employeeId = await matchAuthor(repo.server_id, entry.author)
// UPSERT (중복 무시)
const result = await execute(`
INSERT INTO wr_commit_log (
repo_id, commit_hash, commit_message, commit_author,
commit_date, employee_id, synced_at
) VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (repo_id, commit_hash) DO NOTHING
`, [
repoId,
`r${entry.revision}`, // SVN: r123 형식
entry.message,
entry.author,
entry.date,
employeeId
])
if (result.rowCount && result.rowCount > 0) {
insertedCount++
}
}
// 동기화 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_at = NOW(), last_sync_status = 'SUCCESS', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, `${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`])
return {
success: true,
message: `동기화 완료: ${entries.length}개 커밋 조회, ${insertedCount}개 신규 저장`,
commitCount: insertedCount
}
} catch (error: any) {
console.error('SVN sync error:', error)
// 실패 상태 업데이트
await execute(`
UPDATE wr_repository
SET last_sync_status = 'FAILED', last_sync_message = $2
WHERE repo_id = $1
`, [repoId, error.message?.substring(0, 500)])
return { success: false, message: error.message || '동기화 실패' }
}
}
/**
* 프로젝트의 모든 SVN 저장소 동기화
*/
export async function syncProjectSvnRepositories(projectId: number): Promise<{ success: boolean; results: any[] }> {
const repos = await query(`
SELECT r.repo_id
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.project_id = $1 AND r.is_active = true AND s.server_type = 'SVN'
`, [projectId])
const results = []
for (const repo of repos) {
const result = await syncSvnRepository(repo.repo_id)
results.push({ repoId: repo.repo_id, ...result })
}
return { success: results.every(r => r.success), results }
}

24
server/utils/user.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { H3Event } from 'h3'
import { queryOne } from './db'
import { getAuthenticatedUserId } from './session'
/**
* 현재 로그인한 사용자의 ID 조회
*/
export async function getCurrentUserId(event: H3Event): Promise<number | null> {
return await getAuthenticatedUserId(event)
}
/**
* 현재 로그인한 사용자의 이메일 조회
*/
export async function getCurrentUserEmail(event: H3Event): Promise<string | null> {
const userId = await getAuthenticatedUserId(event)
if (!userId) return null
const user = await queryOne<{ employee_email: string }>(`
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
`, [userId])
return user?.employee_email || null
}

95
server/utils/week-calc.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* ISO 8601 주차 계산 유틸리티
*/
export interface WeekInfo {
year: number
week: number
startDate: Date // 월요일
endDate: Date // 일요일
startDateStr: string // YYYY-MM-DD
endDateStr: string
}
/**
* 날짜를 YYYY-MM-DD 문자열로 변환
*/
export function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/**
* 특정 날짜의 ISO 주차 정보 반환
*/
export function getWeekInfo(date: Date = new Date()): WeekInfo {
const target = new Date(date)
target.setHours(0, 0, 0, 0)
// 목요일 기준으로 연도 판단 (ISO 규칙)
const thursday = new Date(target)
thursday.setDate(target.getDate() - ((target.getDay() + 6) % 7) + 3)
const year = thursday.getFullYear()
const firstThursday = new Date(year, 0, 4)
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3)
const week = Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1
// 해당 주의 월요일
const monday = new Date(target)
monday.setDate(target.getDate() - ((target.getDay() + 6) % 7))
// 해당 주의 일요일
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
return {
year,
week,
startDate: monday,
endDate: sunday,
startDateStr: formatDate(monday),
endDateStr: formatDate(sunday)
}
}
/**
* ISO 주차 문자열 반환 (예: "2026-W01")
*/
export function formatWeekString(year: number, week: number): string {
return `${year}-W${week.toString().padStart(2, '0')}`
}
/**
* 지난 주 정보
*/
export function getLastWeekInfo(): WeekInfo {
const lastWeek = new Date()
lastWeek.setDate(lastWeek.getDate() - 7)
return getWeekInfo(lastWeek)
}
/**
* 특정 연도/주차의 날짜 범위 반환
*/
export function getWeekDates(year: number, week: number): { startDate: Date, endDate: Date } {
// 해당 연도의 1월 4일 (1주차에 반드시 포함)
const jan4 = new Date(year, 0, 4)
// 1월 4일이 포함된 주의 월요일
const firstMonday = new Date(jan4)
firstMonday.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7))
// 원하는 주차의 월요일
const targetMonday = new Date(firstMonday)
targetMonday.setDate(firstMonday.getDate() + (week - 1) * 7)
// 일요일
const targetSunday = new Date(targetMonday)
targetSunday.setDate(targetMonday.getDate() + 6)
return { startDate: targetMonday, endDate: targetSunday }
}