대시보드와 주간보고 기능 업데이트

This commit is contained in:
2026-01-10 14:40:01 +09:00
parent 0dd4b561f0
commit e4627caa4c
26 changed files with 3329 additions and 1720 deletions

View 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 외의 텍스트는 절대 출력하지 마세요`
}

View 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
}
]
}

View File

@@ -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,

View File

@@ -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
}
})
}
})

View File

@@ -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,

View File

@@ -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 }
})

View File

@@ -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: '사용자를 찾을 수 없습니다.' })

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
})
}
})

View File

@@ -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,

View 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
View 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
}