대시보드와 주간보고 기능 업데이트
This commit is contained in:
133
backend/api/ai/parse-my-report-image.post.ts
Normal file
133
backend/api/ai/parse-my-report-image.post.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAIVision } from '../../utils/openai'
|
||||
|
||||
/**
|
||||
* 개인 주간보고 이미지 분석 (OpenAI Vision)
|
||||
* POST /api/ai/parse-my-report-image
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ images: string[] }>(event)
|
||||
|
||||
if (!body.images || body.images.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '분석할 이미지를 업로드해주세요.' })
|
||||
}
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status = 'IN_PROGRESS'
|
||||
`)
|
||||
|
||||
// 프로젝트 목록을 ID 포함해서 전달
|
||||
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
|
||||
|
||||
// OpenAI Vision 분석
|
||||
const prompt = buildImagePrompt(projectList)
|
||||
console.log('=== AI 이미지 분석 시작 ===')
|
||||
console.log('이미지 개수:', body.images.length)
|
||||
console.log('프로젝트 목록:', projectList)
|
||||
|
||||
const aiResponse = await callOpenAIVision(prompt, body.images)
|
||||
console.log('=== AI 응답 (raw) ===')
|
||||
console.log(aiResponse)
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
console.log('=== AI 응답 (parsed) ===')
|
||||
console.log(JSON.stringify(parsed, null, 2))
|
||||
} catch (e) {
|
||||
console.error('=== JSON 파싱 실패 ===')
|
||||
console.error(e)
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
if (parsed.projects) {
|
||||
for (const proj of parsed.projects) {
|
||||
if (!proj.matchedProjectId && proj.projectName) {
|
||||
const matched = projects.find((p: any) =>
|
||||
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
|
||||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
|
||||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
|
||||
)
|
||||
if (matched) {
|
||||
proj.matchedProjectId = matched.project_id
|
||||
proj.projectName = matched.project_name
|
||||
}
|
||||
}
|
||||
|
||||
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0,
|
||||
isCompleted: t.isCompleted !== false
|
||||
}))
|
||||
|
||||
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
|
||||
parsed.projects = parsed.projects.filter((proj: any) =>
|
||||
(proj.workTasks && proj.workTasks.length > 0) ||
|
||||
(proj.planTasks && proj.planTasks.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed,
|
||||
projects
|
||||
}
|
||||
})
|
||||
|
||||
function buildImagePrompt(projectList: string): string {
|
||||
return `당신은 주간보고 내용을 분석하는 AI입니다.
|
||||
이미지에서 주간보고 내용을 추출하여 JSON으로 반환해주세요.
|
||||
이미지에 여러 사람의 내용이 있어도 모두 추출하여 하나의 보고서로 통합해주세요.
|
||||
|
||||
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
|
||||
${projectList}
|
||||
|
||||
⚠️ 중요: 이미지에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
|
||||
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
|
||||
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
|
||||
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
|
||||
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만 출력하세요:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "이미지에서 추출한 원본 프로젝트명",
|
||||
"matchedProjectId": 5,
|
||||
"workTasks": [
|
||||
{"description": "작업내용", "hours": 8, "isCompleted": true}
|
||||
],
|
||||
"planTasks": [
|
||||
{"description": "계획내용", "hours": 8}
|
||||
]
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가일정 내용 또는 null",
|
||||
"remarkDescription": "기타사항 내용 또는 null"
|
||||
}
|
||||
|
||||
규칙:
|
||||
1. 이미지에서 모든 주간보고 내용을 추출
|
||||
2. projectName은 이미지에서 추출한 원본 텍스트 그대로
|
||||
3. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
|
||||
4. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
|
||||
5. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
|
||||
6. 시간이 명시되지 않은 경우 hours는 0으로
|
||||
7. JSON 외의 텍스트는 절대 출력하지 마세요`
|
||||
}
|
||||
152
backend/api/ai/parse-my-report.post.ts
Normal file
152
backend/api/ai/parse-my-report.post.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAI } from '../../utils/openai'
|
||||
|
||||
interface ParsedTask {
|
||||
description: string
|
||||
hours: number
|
||||
isCompleted?: boolean
|
||||
}
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
matchedProjectId: number | null
|
||||
workTasks: ParsedTask[]
|
||||
planTasks: ParsedTask[]
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 개인 주간보고 텍스트 분석 (OpenAI)
|
||||
* POST /api/ai/parse-my-report
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ rawText: string }>(event)
|
||||
|
||||
if (!body.rawText || body.rawText.trim().length < 5) {
|
||||
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
|
||||
}
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status = 'IN_PROGRESS'
|
||||
`)
|
||||
|
||||
// 프로젝트 목록을 ID 포함해서 전달
|
||||
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
|
||||
|
||||
// OpenAI 분석
|
||||
const prompt = buildMyReportPrompt(body.rawText, projectList)
|
||||
const aiResponse = await callOpenAI(prompt, true)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
if (parsed.projects) {
|
||||
for (const proj of parsed.projects) {
|
||||
if (!proj.matchedProjectId && proj.projectName) {
|
||||
const matched = projects.find((p: any) =>
|
||||
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
|
||||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
|
||||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
|
||||
)
|
||||
if (matched) {
|
||||
proj.matchedProjectId = matched.project_id
|
||||
proj.projectName = matched.project_name
|
||||
}
|
||||
}
|
||||
|
||||
// workTasks 기본값
|
||||
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0,
|
||||
isCompleted: t.isCompleted !== false
|
||||
}))
|
||||
|
||||
// planTasks 기본값
|
||||
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0
|
||||
}))
|
||||
}
|
||||
|
||||
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
|
||||
parsed.projects = parsed.projects.filter((proj: any) =>
|
||||
(proj.workTasks && proj.workTasks.length > 0) ||
|
||||
(proj.planTasks && proj.planTasks.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed,
|
||||
projects
|
||||
}
|
||||
})
|
||||
|
||||
function buildMyReportPrompt(rawText: string, projectList: string): any[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 주간보고 내용을 분석하는 AI입니다.
|
||||
사용자가 입력한 텍스트를 분석하여 프로젝트별 업무 내용을 추출해주세요.
|
||||
|
||||
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
|
||||
${projectList}
|
||||
|
||||
⚠️ 중요: 입력 텍스트에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
|
||||
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
|
||||
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
|
||||
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
|
||||
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
|
||||
|
||||
응답은 반드시 아래 JSON 형식으로만 출력하세요:
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "입력에서 추출한 원본 프로젝트명",
|
||||
"matchedProjectId": 5,
|
||||
"workTasks": [
|
||||
{"description": "작업내용", "hours": 8, "isCompleted": true}
|
||||
],
|
||||
"planTasks": [
|
||||
{"description": "계획내용", "hours": 8}
|
||||
]
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가일정 내용 또는 null",
|
||||
"remarkDescription": "기타사항 내용 또는 null"
|
||||
}
|
||||
|
||||
규칙:
|
||||
1. projectName은 입력 텍스트에서 추출한 원본 그대로
|
||||
2. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
|
||||
3. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
|
||||
4. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
|
||||
5. 시간이 명시되지 않은 경우 hours는 0으로
|
||||
6. JSON 외의 텍스트는 절대 출력하지 마세요`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: rawText
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,26 +1,48 @@
|
||||
import { queryOne } from '../../utils/db'
|
||||
import { getSession, refreshSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자 정보
|
||||
* GET /api/auth/current-user
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!userId) {
|
||||
if (!sessionId) {
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// DB에서 세션 조회
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
// 세션이 만료되었거나 없음 → 쿠키 삭제
|
||||
deleteSessionCookie(event)
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// 사용자 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info
|
||||
WHERE employee_id = $1 AND is_active = true
|
||||
`, [parseInt(userId)])
|
||||
`, [session.employeeId])
|
||||
|
||||
if (!employee) {
|
||||
deleteCookie(event, 'user_id')
|
||||
deleteSessionCookie(event)
|
||||
return { user: null }
|
||||
}
|
||||
|
||||
// 세션 갱신 (Sliding Expiration - 10분 연장)
|
||||
await refreshSession(sessionId)
|
||||
|
||||
// 로그인 이력의 last_active_at도 업데이트
|
||||
if (session.loginHistoryId) {
|
||||
await execute(`
|
||||
UPDATE wr_login_history
|
||||
SET last_active_at = NOW()
|
||||
WHERE history_id = $1
|
||||
`, [session.loginHistoryId])
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
employeeId: employee.employee_id,
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { getSession, getSessionIdFromCookie, deleteSessionCookie, SESSION_TIMEOUT_MINUTES } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 본인 로그인 이력 조회
|
||||
* GET /api/auth/login-history
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
const currentHistoryId = getCookie(event, 'login_history_id')
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!userId) {
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
deleteSessionCookie(event)
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
|
||||
}
|
||||
|
||||
// 현재 활성 세션 ID 목록 조회
|
||||
const activeSessions = await query<any>(`
|
||||
SELECT login_history_id FROM wr_session
|
||||
WHERE employee_id = $1 AND expires_at > NOW()
|
||||
`, [session.employeeId])
|
||||
|
||||
const activeHistoryIds = new Set(activeSessions.map(s => s.login_history_id))
|
||||
|
||||
// 로그인 이력 조회
|
||||
const history = await query<any>(`
|
||||
SELECT
|
||||
history_id,
|
||||
@@ -24,17 +39,37 @@ export default defineEventHandler(async (event) => {
|
||||
WHERE employee_id = $1
|
||||
ORDER BY login_at DESC
|
||||
LIMIT 50
|
||||
`, [userId])
|
||||
`, [session.employeeId])
|
||||
|
||||
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
history: history.map(h => ({
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
lastActiveAt: h.last_active_at,
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
history: history.map(h => {
|
||||
const isCurrentSession = h.history_id === session.loginHistoryId
|
||||
const isActiveSession = activeHistoryIds.has(h.history_id)
|
||||
|
||||
// 세션 상태 판단
|
||||
let sessionStatus: 'active' | 'logout' | 'expired'
|
||||
if (h.logout_at) {
|
||||
sessionStatus = 'logout'
|
||||
} else if (isActiveSession) {
|
||||
sessionStatus = 'active'
|
||||
} else {
|
||||
// 활성 세션에 없으면 만료
|
||||
sessionStatus = 'expired'
|
||||
}
|
||||
|
||||
return {
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
lastActiveAt: h.last_active_at,
|
||||
isCurrentSession,
|
||||
sessionStatus
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { query, insertReturning, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
|
||||
interface LoginBody {
|
||||
email: string
|
||||
@@ -7,12 +7,13 @@ interface LoginBody {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일+이름 로그인 (임시)
|
||||
* 이메일+이름 로그인
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<LoginBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
if (!body.email || !body.name) {
|
||||
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
|
||||
@@ -60,18 +61,16 @@ export default defineEventHandler(async (event) => {
|
||||
RETURNING history_id
|
||||
`, [employeeData.employee_id, clientIp, emailLower])
|
||||
|
||||
// 쿠키에 사용자 정보 저장
|
||||
setCookie(event, 'user_id', String(employeeData.employee_id), {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
// DB 기반 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employeeData.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
setCookie(event, 'login_history_id', String(loginHistory.history_id), {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
// 세션 쿠키 설정
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
import { execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { getSession, deleteSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const historyId = getCookie(event, 'login_history_id')
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
// 로그아웃 이력 기록
|
||||
if (historyId) {
|
||||
await execute(`
|
||||
UPDATE wr_login_history
|
||||
SET logout_at = NOW(), logout_ip = $1
|
||||
WHERE history_id = $2
|
||||
`, [clientIp, historyId])
|
||||
if (sessionId) {
|
||||
// 세션 정보 조회
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
// 로그아웃 이력 기록
|
||||
if (session?.loginHistoryId) {
|
||||
await execute(`
|
||||
UPDATE wr_login_history
|
||||
SET logout_at = NOW(), logout_ip = $1
|
||||
WHERE history_id = $2
|
||||
`, [clientIp, session.loginHistoryId])
|
||||
}
|
||||
|
||||
// DB에서 세션 삭제
|
||||
await deleteSession(sessionId)
|
||||
}
|
||||
|
||||
// 쿠키 삭제
|
||||
deleteCookie(event, 'user_id')
|
||||
deleteCookie(event, 'login_history_id')
|
||||
// 세션 쿠키 삭제
|
||||
deleteSessionCookie(event)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { queryOne } from '../../utils/db'
|
||||
import { getSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
|
||||
|
||||
/**
|
||||
* 로그인된 사용자 정보 조회
|
||||
* 로그인된 사용자 상세 정보 조회
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
const sessionId = getSessionIdFromCookie(event)
|
||||
|
||||
if (!sessionId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
// DB에서 세션 조회
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
deleteSessionCookie(event)
|
||||
throw createError({ statusCode: 401, message: '세션이 만료되었습니다. 다시 로그인해주세요.' })
|
||||
}
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT
|
||||
employee_id,
|
||||
@@ -22,7 +31,7 @@ export default defineEventHandler(async (event) => {
|
||||
is_active
|
||||
FROM wr_employee_info
|
||||
WHERE employee_id = $1
|
||||
`, [userId])
|
||||
`, [session.employeeId])
|
||||
|
||||
if (!employee) {
|
||||
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { queryOne, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
import { createSession, setSessionCookie } from '../../utils/session'
|
||||
|
||||
interface SelectUserBody {
|
||||
employeeId: number
|
||||
@@ -10,6 +11,8 @@ interface SelectUserBody {
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<SelectUserBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
const userAgent = getHeader(event, 'user-agent') || null
|
||||
|
||||
if (!body.employeeId) {
|
||||
throw createError({ statusCode: 400, message: '사용자를 선택해주세요.' })
|
||||
@@ -26,16 +29,22 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 로그인 이력 추가
|
||||
await execute(`
|
||||
INSERT INTO wr_login_history (employee_id) VALUES ($1)
|
||||
`, [employee.employee_id])
|
||||
const loginHistory = await insertReturning(`
|
||||
INSERT INTO wr_login_history (employee_id, login_ip, login_email)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING history_id
|
||||
`, [employee.employee_id, clientIp, employee.employee_email])
|
||||
|
||||
// 쿠키 설정
|
||||
setCookie(event, 'user_id', String(employee.employee_id), {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
path: '/'
|
||||
})
|
||||
// DB 기반 세션 생성
|
||||
const sessionId = await createSession(
|
||||
employee.employee_id,
|
||||
loginHistory.history_id,
|
||||
clientIp,
|
||||
userAgent
|
||||
)
|
||||
|
||||
// 세션 쿠키 설정
|
||||
setSessionCookie(event, sessionId)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -54,14 +54,15 @@ export default defineEventHandler(async (event) => {
|
||||
JOIN wr_weekly_report r ON t.report_id = r.report_id
|
||||
AND r.report_year = $1 AND r.report_week = $2
|
||||
JOIN wr_employee_info e ON r.author_id = e.employee_id
|
||||
WHERE p.project_status = 'IN_PROGRESS'
|
||||
GROUP BY p.project_id, p.project_code, p.project_name
|
||||
ORDER BY work_hours DESC
|
||||
`, [year, week])
|
||||
|
||||
// 3. 전체 요약
|
||||
const activeEmployees = employeeStats.length
|
||||
const submittedCount = employeeStats.filter((e: any) => e.report_id).length
|
||||
const submittedCount = employeeStats.filter((e: any) =>
|
||||
e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
|
||||
).length
|
||||
const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0)
|
||||
const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0)
|
||||
|
||||
@@ -86,7 +87,7 @@ export default defineEventHandler(async (event) => {
|
||||
planHours: parseFloat(e.plan_hours) || 0,
|
||||
workProjectCount: parseInt(e.work_project_count) || 0,
|
||||
planProjectCount: parseInt(e.plan_project_count) || 0,
|
||||
isSubmitted: !!e.report_id
|
||||
isSubmitted: e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
|
||||
})),
|
||||
projects: projectStats.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
|
||||
@@ -6,8 +6,32 @@ const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY
|
||||
})
|
||||
|
||||
// 문자열 해시 함수 (seed용)
|
||||
function hashCode(str: string): number {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // 32bit 정수로 변환
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
interface QualityScore {
|
||||
summary: string // 총평 (맨 위)
|
||||
specificity: { score: number; improvement: string } // 구체성
|
||||
completeness: { score: number; improvement: string } // 완결성
|
||||
timeEstimation: { score: number; improvement: string } // 시간산정
|
||||
planning: { score: number; improvement: string } // 계획성
|
||||
overall: number // 종합점수
|
||||
bestPractice: { // 모범 답안
|
||||
workTasks: string[] // 금주 실적 모범 답안
|
||||
planTasks: string[] // 차주 계획 모범 답안
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 PMO AI 리뷰
|
||||
* 주간보고 PMO AI 리뷰 - 작성 품질 점수 + 모범 답안
|
||||
* POST /api/report/review
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -77,61 +101,75 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
// OpenAI PMO 리뷰 요청
|
||||
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)이자 주간보고 작성 코치입니다.
|
||||
개발자들이 더 나은 주간보고를 작성할 수 있도록 구체적인 피드백과 가이드를 제공해주세요.
|
||||
// OpenAI 품질 점수 + 모범 답안 요청
|
||||
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)입니다.
|
||||
주간보고의 작성 품질을 평가하고, 모범 답안을 제시해주세요.
|
||||
|
||||
[주간보고 작성의 목적]
|
||||
- 프로젝트 진행 현황을 명확히 파악
|
||||
- 일정 지연이나 리스크를 사전에 감지
|
||||
- 팀원 간 업무 공유 및 협업 촉진
|
||||
[평가 항목] (각 1~10점)
|
||||
1. 구체성 (specificity): 작업 내용이 어떤 기능/모듈인지 구체적으로 작성되었는지
|
||||
2. 완결성 (completeness): 필수 정보 포함 여부
|
||||
3. 시간산정 (timeEstimation): 작업 시간이 내용 대비 적절하게 배분되었는지
|
||||
4. 계획성 (planning): 차주 계획이 실현 가능하고 명확한 목표가 있는지
|
||||
|
||||
[검토 기준 - 엄격하게 적용]
|
||||
[완결성 상세 기준] - 엄격하게 적용
|
||||
- 진행중 작업에 진척률(%)이 없으면 -2점
|
||||
- 진행중 작업에 완료 예정일이 없으면 -2점
|
||||
- 완료 작업인데 산출물/결과 언급이 없으면 -1점
|
||||
- 상태(완료/진행중)가 명확하지 않으면 -1점
|
||||
|
||||
1. **실적의 구체성** (가장 중요!)
|
||||
- "DB 작업", "화면 개발", "API 개발" 같은 모호한 표현 지양
|
||||
- 좋은 예시: "사용자 관리 테이블 3개(user, role, permission) 설계 및 생성"
|
||||
- 좋은 예시: "로그인 API 개발 - JWT 토큰 발급, 리프레시 토큰 구현"
|
||||
- 좋은 예시: "검색 화면 UI 구현 - 필터 조건 5개, 페이징, 엑셀 다운로드"
|
||||
- 어떤 기능/모듈/화면인지, 무엇을 구체적으로 했는지 명시되어야 함
|
||||
[계획성 상세 기준] - 엄격하게 적용
|
||||
- 차주 계획에 예상 소요시간 근거가 없으면 -1점
|
||||
- 차주 계획에 목표 완료일/산출물이 없으면 -2점
|
||||
- 단순 "~할 예정", "~진행" 만 있고 구체적 목표가 없으면 -2점
|
||||
- 실현 가능성이 낮은 과도한 계획이면 -1점
|
||||
|
||||
2. **일정의 명확성**
|
||||
- "진행중"만 있고 완료 예정일이 없으면 부족
|
||||
- 언제 완료될 예정인지, 진척률은 얼마인지 표기 권장
|
||||
- 좋은 예시: "사용자 관리 화면 개발 (70% 완료, 1/10 완료 예정)"
|
||||
[점수 기준]
|
||||
- 1~3점: 매우 부족 (내용이 거의 없거나 한 단어 수준)
|
||||
- 4~5점: 부족 (진척률/예정일 누락, 모호한 표현)
|
||||
- 6~7점: 보통 (기본 내용은 있으나 구체성 부족)
|
||||
- 8~9점: 양호 (진척률, 예정일, 산출물 모두 명시)
|
||||
- 10점: 우수 (완벽한 모범 사례)
|
||||
|
||||
3. **시간 산정의 적절성**
|
||||
- 8시간(1일) 이상 작업은 세부 내역이 필요
|
||||
- 16시간(2일) 이상인데 내용이 한 줄이면 분리 필요
|
||||
- "회의", "검토" 등은 별도 기재 권장
|
||||
※ 진행중 작업에 진척률/예정일이 없으면 완결성은 6점 이하로 평가하세요.
|
||||
※ 차주 계획에 구체적 목표가 없으면 계획성은 6점 이하로 평가하세요.
|
||||
|
||||
4. **차주 계획의 실현 가능성**
|
||||
- 계획이 너무 추상적이면 실행하기 어려움
|
||||
- 구체적인 목표와 예상 산출물 명시 필요
|
||||
- 좋은 예시: "결제 모듈 연동 - PG사 API 연동, 결제 테스트 완료 목표"
|
||||
[모범 답안 작성 규칙]
|
||||
- 사용자가 작성한 내용을 기반으로 더 구체적으로 보완
|
||||
- 같은 프로젝트명, 비슷한 작업 내용을 유지하되 구체성 추가
|
||||
- 진행중인 작업은 반드시 진척률(%)과 완료 예정일 추가
|
||||
- 시간이 긴 작업은 세부 내역 포함
|
||||
- 차주 계획은 목표 산출물과 예상 완료일 명시
|
||||
- 형식: "프로젝트명 / 작업내용 (세부사항, 진척률, 예정일) / 시간h / 상태"
|
||||
|
||||
[피드백 작성 규칙]
|
||||
- 각 Task별로 구체적인 개선 제안 제시
|
||||
- 잘 작성된 부분은 "✅" 로 인정
|
||||
- 보완이 필요한 부분은 "📝" 로 개선 방향 제시
|
||||
- 일정 관련 질문은 "📅" 로 표시
|
||||
- 리스크/우려사항은 "⚠️" 로 경고
|
||||
- **반드시 어떻게 수정하면 좋을지 예시를 들어 설명**
|
||||
- 친절하지만 명확하게, 구체적인 작성 예시를 포함
|
||||
- 마지막에 전체적인 작성 팁 1-2개 추가
|
||||
[응답 규칙]
|
||||
- 반드시 아래 JSON 형식으로만 응답
|
||||
- summary: 전체적인 총평 (30~60자, 격려 포함)
|
||||
- improvement: 각 항목별 개선 포인트 (15~30자, 구체적으로)
|
||||
- bestPractice: 모범 답안 (workTasks, planTasks 배열)
|
||||
- JSON 외의 텍스트는 절대 포함하지 마세요`
|
||||
|
||||
[피드백 톤]
|
||||
- 비난하지 않고 코칭하는 느낌으로
|
||||
- "~하면 더 좋겠습니다", "~로 수정해보시면 어떨까요?" 형태로
|
||||
- 개선점뿐 아니라 잘한 점도 언급`
|
||||
const userPrompt = `다음 주간보고의 작성 품질을 평가하고, 모범 답안을 만들어주세요.
|
||||
|
||||
const userPrompt = `다음 주간보고를 PMO 관점에서 상세히 리뷰해주세요.
|
||||
특히 실적과 계획이 구체적으로 작성되었는지, 일정이 명확한지 중점적으로 검토해주세요.
|
||||
모호한 표현이 있다면 어떻게 수정하면 좋을지 예시와 함께 피드백해주세요.
|
||||
${taskText}
|
||||
|
||||
${taskText}`
|
||||
아래 JSON 형식으로만 응답하세요:
|
||||
{
|
||||
"summary": "총평 (격려 포함)",
|
||||
"specificity": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"completeness": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"timeEstimation": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"planning": { "score": 숫자, "improvement": "개선포인트" },
|
||||
"overall": 종합점수(소수점1자리),
|
||||
"bestPractice": {
|
||||
"workTasks": ["모범답안1", "모범답안2", ...],
|
||||
"planTasks": ["모범답안1", "모범답안2", ...]
|
||||
}
|
||||
}`
|
||||
|
||||
try {
|
||||
// Task 내용 기반 seed 생성 (같은 내용 = 같은 점수)
|
||||
const seed = hashCode(taskText)
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
@@ -139,29 +177,52 @@ ${taskText}`
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
max_tokens: 1500,
|
||||
temperature: 0.7
|
||||
temperature: 0.2, // 낮춰서 일관성 강화
|
||||
seed: seed // 같은 내용 = 같은 seed = 같은 결과
|
||||
})
|
||||
|
||||
const review = response.choices[0]?.message?.content || '리뷰를 생성할 수 없습니다.'
|
||||
const content = response.choices[0]?.message?.content || ''
|
||||
|
||||
// JSON 파싱
|
||||
let qualityScore: QualityScore
|
||||
try {
|
||||
// JSON 블록 추출 (```json ... ``` 형태 처리)
|
||||
let jsonStr = content
|
||||
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1]
|
||||
} else {
|
||||
// { } 사이 추출
|
||||
const braceMatch = content.match(/\{[\s\S]*\}/)
|
||||
if (braceMatch) {
|
||||
jsonStr = braceMatch[0]
|
||||
}
|
||||
}
|
||||
qualityScore = JSON.parse(jsonStr)
|
||||
} catch (parseError) {
|
||||
console.error('JSON 파싱 실패:', content)
|
||||
throw new Error('AI 응답을 파싱할 수 없습니다.')
|
||||
}
|
||||
|
||||
const reviewedAt = new Date().toISOString()
|
||||
|
||||
// DB에 저장
|
||||
// DB에 저장 (ai_review에 JSON 문자열로 저장)
|
||||
await query(`
|
||||
UPDATE wr_weekly_report
|
||||
SET ai_review = $1, ai_review_at = $2
|
||||
WHERE report_id = $3
|
||||
`, [review, reviewedAt, reportId])
|
||||
`, [JSON.stringify(qualityScore), reviewedAt, reportId])
|
||||
|
||||
return {
|
||||
success: true,
|
||||
review,
|
||||
qualityScore,
|
||||
reviewedAt
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('OpenAI API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'AI 리뷰 생성 중 오류가 발생했습니다: ' + error.message
|
||||
message: 'AI 품질 평가 중 오류가 발생했습니다: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,26 +32,14 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const q = getQuery(event)
|
||||
const limit = parseInt(q.limit as string) || 100
|
||||
const viewAll = q.viewAll === 'true'
|
||||
|
||||
// 필터 조건 구성
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
let paramIndex = 1
|
||||
|
||||
// 관리자가 viewAll이면 전체 조회, 아니면 본인 것만
|
||||
if (!isAdmin || !viewAll) {
|
||||
// 작성자 필터 (본인 또는 지정된 작성자)
|
||||
if (q.authorId) {
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(q.authorId)
|
||||
} else if (!isAdmin) {
|
||||
// 관리자가 아니면 본인 것만
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(userId)
|
||||
}
|
||||
} else if (q.authorId) {
|
||||
// 관리자가 viewAll이어도 작성자 필터가 있으면 적용
|
||||
// 작성자 필터 (선택된 경우에만 적용)
|
||||
if (q.authorId) {
|
||||
conditions.push(`r.author_id = $${paramIndex++}`)
|
||||
params.push(q.authorId)
|
||||
}
|
||||
@@ -77,6 +65,15 @@ export default defineEventHandler(async (event) => {
|
||||
params.push(q.week)
|
||||
}
|
||||
|
||||
// 특정 주차 이전 필터 (beforeYear, beforeWeek)
|
||||
if (q.beforeYear && q.beforeWeek) {
|
||||
conditions.push(`(r.report_year < $${paramIndex} OR (r.report_year = $${paramIndex} AND r.report_week < $${paramIndex + 1}))`)
|
||||
params.push(q.beforeYear)
|
||||
paramIndex++
|
||||
params.push(q.beforeWeek)
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
// 주차 범위 필터
|
||||
if (q.weekFrom) {
|
||||
conditions.push(`r.report_week >= $${paramIndex++}`)
|
||||
@@ -122,6 +119,8 @@ export default defineEventHandler(async (event) => {
|
||||
r.report_status,
|
||||
r.submitted_at,
|
||||
r.created_at,
|
||||
r.updated_at,
|
||||
r.ai_review,
|
||||
(SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count,
|
||||
(SELECT string_agg(DISTINCT p.project_name, ', ')
|
||||
FROM wr_weekly_report_task t
|
||||
@@ -152,6 +151,8 @@ export default defineEventHandler(async (event) => {
|
||||
reportStatus: r.report_status,
|
||||
submittedAt: r.submitted_at,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
aiReview: r.ai_review,
|
||||
projectCount: parseInt(r.project_count),
|
||||
projectNames: r.project_names,
|
||||
totalWorkHours: parseFloat(r.total_work_hours) || 0,
|
||||
|
||||
18
backend/sql/create_session_table.sql
Normal file
18
backend/sql/create_session_table.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 세션 테이블 (Spring Session JDBC와 유사한 구조)
|
||||
CREATE TABLE IF NOT EXISTS wr_session (
|
||||
session_id VARCHAR(64) PRIMARY KEY, -- 세션 토큰 (랜덤 생성)
|
||||
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
|
||||
login_history_id INTEGER REFERENCES wr_login_history(history_id),
|
||||
created_at TIMESTAMP DEFAULT NOW(), -- 세션 생성 시간
|
||||
last_access_at TIMESTAMP DEFAULT NOW(), -- 마지막 접근 시간
|
||||
expires_at TIMESTAMP NOT NULL, -- 만료 시간
|
||||
login_ip VARCHAR(45), -- 로그인 IP
|
||||
user_agent TEXT -- 브라우저 정보
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_wr_session_employee_id ON wr_session(employee_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wr_session_expires_at ON wr_session(expires_at);
|
||||
|
||||
-- 만료된 세션 자동 정리 (선택사항 - 배치로 실행)
|
||||
-- DELETE FROM wr_session WHERE expires_at < NOW();
|
||||
198
backend/utils/session.ts
Normal file
198
backend/utils/session.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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 getSession(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 getSession(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
|
||||
}
|
||||
Reference in New Issue
Block a user