기능구현중

This commit is contained in:
2026-01-11 14:32:45 +09:00
parent 56fc4c3005
commit 0205e8d437
17 changed files with 1015 additions and 70 deletions

View File

@@ -0,0 +1,59 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 전체 VCS 동기화 상태 조회 (관리자용)
* GET /api/admin/vcs/status
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 저장소별 동기화 상태
const repos = await query(`
SELECT
r.repo_id, r.repo_name, r.repo_path,
r.last_sync_at, r.last_sync_status, r.last_sync_message,
s.server_type, s.server_name,
p.project_name,
(SELECT COUNT(*) FROM wr_commit_log c WHERE c.repo_id = r.repo_id) as commit_count
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
WHERE r.is_active = true
ORDER BY r.last_sync_at DESC NULLS LAST
`)
// 전체 통계
const stats = await query(`
SELECT
COUNT(DISTINCT r.repo_id) as total_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'SUCCESS' THEN r.repo_id END) as success_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_status = 'FAILED' THEN r.repo_id END) as failed_repos,
COUNT(DISTINCT CASE WHEN r.last_sync_at IS NULL THEN r.repo_id END) as never_synced,
(SELECT COUNT(*) FROM wr_commit_log) as total_commits
FROM wr_repository r
WHERE r.is_active = true
`)
return {
repositories: repos.map(r => ({
repoId: r.repo_id,
repoName: r.repo_name,
repoPath: r.repo_path,
serverType: r.server_type,
serverName: r.server_name,
projectName: r.project_name,
lastSyncAt: r.last_sync_at,
lastSyncStatus: r.last_sync_status,
lastSyncMessage: r.last_sync_message,
commitCount: parseInt(r.commit_count || '0')
})),
stats: {
totalRepos: parseInt(stats[0]?.total_repos || '0'),
successRepos: parseInt(stats[0]?.success_repos || '0'),
failedRepos: parseInt(stats[0]?.failed_repos || '0'),
neverSynced: parseInt(stats[0]?.never_synced || '0'),
totalCommits: parseInt(stats[0]?.total_commits || '0')
}
}
})

View File

@@ -0,0 +1,63 @@
import { query } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { syncGitRepository } from '../../../utils/git-sync'
import { syncSvnRepository } from '../../../utils/svn-sync'
/**
* 전체 VCS 저장소 동기화 (관리자용)
* POST /api/admin/vcs/sync-all
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
// 모든 활성 저장소 조회
const repos = await query(`
SELECT r.repo_id, r.repo_name, s.server_type
FROM wr_repository r
JOIN wr_vcs_server s ON r.server_id = s.server_id
WHERE r.is_active = true AND s.is_active = true
ORDER BY r.repo_name
`)
const results: any[] = []
for (const repo of repos) {
try {
let result
if (repo.server_type === 'GIT') {
result = await syncGitRepository(repo.repo_id)
} else if (repo.server_type === 'SVN') {
result = await syncSvnRepository(repo.repo_id)
} else {
result = { success: false, message: '지원하지 않는 서버 타입' }
}
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
...result
})
} catch (e: any) {
results.push({
repoId: repo.repo_id,
repoName: repo.repo_name,
serverType: repo.server_type,
success: false,
message: e.message
})
}
}
const successCount = results.filter(r => r.success).length
const failCount = results.filter(r => !r.success).length
return {
success: failCount === 0,
message: `동기화 완료: 성공 ${successCount}개, 실패 ${failCount}`,
totalRepos: repos.length,
successCount,
failCount,
results
}
})

View File

@@ -1,9 +1,13 @@
/**
* Google OAuth 시작
* GET /api/auth/google
*
* Query params:
* - extend: 'groups' - 구글 그룹 접근 권한 추가 요청
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const query = getQuery(event)
const clientId = config.googleClientId || process.env.GOOGLE_CLIENT_ID
const redirectUri = config.googleRedirectUri || process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3000/api/auth/google/callback'
@@ -12,7 +16,18 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 500, message: 'Google OAuth가 설정되지 않았습니다.' })
}
const scope = encodeURIComponent('openid email profile')
// 기본 scope + 확장 scope
let scopes = ['openid', 'email', 'profile']
// 구글 그룹 권한 요청 시 추가 scope
if (query.extend === 'groups') {
scopes.push(
'https://www.googleapis.com/auth/gmail.readonly', // 그룹 메일 읽기
'https://www.googleapis.com/auth/cloud-identity.groups.readonly' // 그룹 정보 읽기
)
}
const scope = encodeURIComponent(scopes.join(' '))
const state = Math.random().toString(36).substring(7) // CSRF 방지
// state를 쿠키에 저장

View File

@@ -35,7 +35,9 @@ export default defineEventHandler(async (event) => {
updated_ip,
password_hash,
google_id,
google_email
google_email,
synology_id,
synology_email
FROM wr_employee_info
WHERE employee_id = $1
`, [session.employeeId])
@@ -60,7 +62,9 @@ export default defineEventHandler(async (event) => {
updatedIp: employee.updated_ip,
hasPassword: !!employee.password_hash,
googleId: employee.google_id,
googleEmail: employee.google_email
googleEmail: employee.google_email,
synologyId: employee.synology_id,
synologyEmail: employee.synology_email
}
}
})

View File

@@ -0,0 +1,107 @@
import { queryOne, execute } from '../../../utils/db'
import { createSession } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
/**
* Synology SSO 콜백
* GET /api/auth/synology/callback
*
* Synology SSO Server에서 인증 후 리다이렉트되는 엔드포인트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const query = getQuery(event)
const ip = getClientIp(event)
const code = query.code as string
const error = query.error as string
if (error) {
return sendRedirect(event, `/login?error=${encodeURIComponent('Synology 인증이 취소되었습니다.')}`)
}
if (!code) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('인증 코드가 없습니다.'))
}
try {
// 1. 코드로 액세스 토큰 교환
const tokenResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOAccessToken.cgi`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: config.synologyClientId,
client_secret: config.synologyClientSecret,
redirect_uri: config.synologyRedirectUri
}).toString()
})
if (!tokenResponse.access_token) {
console.error('Synology token error:', tokenResponse)
return sendRedirect(event, '/login?error=' + encodeURIComponent('토큰 획득 실패'))
}
// 2. 액세스 토큰으로 사용자 정보 조회
const userResponse = await $fetch<any>(`${config.synologyServerUrl}/webman/sso/SSOUserInfo.cgi`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${tokenResponse.access_token}`
}
})
if (!userResponse.data || !userResponse.data.email) {
console.error('Synology user info error:', userResponse)
return sendRedirect(event, '/login?error=' + encodeURIComponent('사용자 정보를 가져올 수 없습니다.'))
}
const synologyEmail = userResponse.data.email
const synologyId = userResponse.data.user_id || userResponse.data.uid
const synologyName = userResponse.data.name || userResponse.data.username
// 3. 이메일로 사용자 매칭
const employee = await queryOne<any>(`
SELECT employee_id, employee_name, is_active, password_hash,
synology_id, synology_email
FROM wr_employee_info
WHERE email = $1
`, [synologyEmail])
if (!employee) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('등록되지 않은 사용자입니다. 관리자에게 문의하세요.'))
}
if (!employee.is_active) {
return sendRedirect(event, '/login?error=' + encodeURIComponent('비활성화된 계정입니다.'))
}
// 4. Synology 계정 연결 정보 업데이트
await execute(`
UPDATE wr_employee_info
SET synology_id = $1, synology_email = $2, synology_linked_at = NOW()
WHERE employee_id = $3
`, [synologyId, synologyEmail, employee.employee_id])
// 5. 로그인 이력 기록
await execute(`
INSERT INTO wr_login_history (employee_id, login_type, login_ip, login_at, login_success, login_email)
VALUES ($1, 'SYNOLOGY', $2, NOW(), true, $3)
`, [employee.employee_id, ip, synologyEmail])
// 6. 세션 생성
await createSession(event, employee.employee_id)
// 7. 비밀번호 미설정 시 설정 페이지로
if (!employee.password_hash) {
return sendRedirect(event, '/set-password?from=synology')
}
// 8. 메인 페이지로 리다이렉트
return sendRedirect(event, '/')
} catch (e: any) {
console.error('Synology OAuth error:', e)
return sendRedirect(event, '/login?error=' + encodeURIComponent('Synology 인증 중 오류가 발생했습니다.'))
}
})

View File

@@ -0,0 +1,26 @@
/**
* Synology SSO 로그인 시작
* GET /api/auth/synology
*
* Synology SSO Server OAuth 2.0 인증 페이지로 리다이렉트
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!config.synologyServerUrl || !config.synologyClientId) {
throw createError({
statusCode: 500,
message: 'Synology SSO가 설정되지 않았습니다.'
})
}
// Synology SSO Server OAuth 인증 URL
const authUrl = new URL(`${config.synologyServerUrl}/webman/sso/SSOOauth.cgi`)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', config.synologyClientId)
authUrl.searchParams.set('redirect_uri', config.synologyRedirectUri)
authUrl.searchParams.set('scope', 'user_id')
authUrl.searchParams.set('state', crypto.randomUUID())
return sendRedirect(event, authUrl.toString())
})

View File

@@ -0,0 +1,102 @@
import { queryOne } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 목록 조회
* GET /api/google-group/list
*
* Cloud Identity Groups API 사용
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
// 사용자의 Google 토큰 조회
const employee = await queryOne<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at
FROM wr_employee_info WHERE employee_id = $1
`, [session.employeeId])
if (!employee?.google_access_token) {
throw createError({
statusCode: 401,
message: 'Google 계정이 연결되지 않았습니다. 먼저 Google 로그인을 해주세요.'
})
}
let accessToken = employee.google_access_token
// 토큰 만료 확인 및 갱신
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
}
try {
// Cloud Identity Groups API로 그룹 목록 조회
const response = await $fetch<any>('https://cloudidentity.googleapis.com/v1/groups:search', {
method: 'GET',
headers: { 'Authorization': `Bearer ${accessToken}` },
query: {
query: 'parent == "customers/my_customer" && "cloudidentity.googleapis.com/groups.discussion_forum" in labels',
pageSize: 100
}
})
return {
groups: (response.groups || []).map((g: any) => ({
groupId: g.name?.split('/')[1],
groupKey: g.groupKey?.id,
displayName: g.displayName,
description: g.description,
email: g.groupKey?.id
}))
}
} catch (e: any) {
console.error('Google Groups API error:', e)
// 권한 없음 에러
if (e.status === 403) {
throw createError({
statusCode: 403,
message: '구글 그룹 접근 권한이 없습니다. 관리자에게 문의하세요.'
})
}
throw createError({
statusCode: 500,
message: '구글 그룹 목록을 가져오는데 실패했습니다.'
})
}
})
/**
* Google 토큰 갱신
*/
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
const config = useRuntimeConfig()
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
if (response.access_token) {
// DB 업데이트
const { execute } = await import('../../utils/db')
await execute(`
UPDATE wr_employee_info
SET google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
WHERE employee_id = $2
`, [response.access_token, employeeId])
return response.access_token
}
throw new Error('토큰 갱신 실패')
}

View File

@@ -0,0 +1,139 @@
import { queryOne, execute } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 구글 그룹 메시지 조회
* GET /api/google-group/messages
*
* Gmail API로 그룹 이메일 조회
*
* Query params:
* - groupEmail: 그룹 이메일 주소 (예: dev-team@company.com)
* - maxResults: 최대 결과 수 (기본 20)
* - after: 이 날짜 이후 메시지 (YYYY-MM-DD)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const query = getQuery(event)
const groupEmail = query.groupEmail as string
const maxResults = parseInt(query.maxResults as string) || 20
const after = query.after as string
if (!groupEmail) {
throw createError({ statusCode: 400, message: '그룹 이메일이 필요합니다.' })
}
// 사용자의 Google 토큰 조회
const employee = await queryOne<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at
FROM wr_employee_info WHERE employee_id = $1
`, [session.employeeId])
if (!employee?.google_access_token) {
throw createError({
statusCode: 401,
message: 'Google 계정이 연결되지 않았습니다.'
})
}
let accessToken = employee.google_access_token
// 토큰 만료 확인 및 갱신
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
}
try {
// Gmail API로 그룹 메일 검색
let searchQuery = `list:${groupEmail}`
if (after) {
searchQuery += ` after:${after}`
}
const listResponse = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages', {
headers: { 'Authorization': `Bearer ${accessToken}` },
query: {
q: searchQuery,
maxResults: maxResults
}
})
if (!listResponse.messages || listResponse.messages.length === 0) {
return { messages: [], total: 0 }
}
// 각 메시지의 상세 정보 조회
const messages = await Promise.all(
listResponse.messages.slice(0, maxResults).map(async (msg: any) => {
const detail = await $fetch<any>(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
query: { format: 'metadata', metadataHeaders: ['Subject', 'From', 'Date', 'To'] }
})
const headers = detail.payload?.headers || []
const getHeader = (name: string) => headers.find((h: any) => h.name === name)?.value || ''
return {
id: msg.id,
threadId: msg.threadId,
subject: getHeader('Subject'),
from: getHeader('From'),
to: getHeader('To'),
date: getHeader('Date'),
snippet: detail.snippet
}
})
)
return {
messages,
total: listResponse.resultSizeEstimate || messages.length
}
} catch (e: any) {
console.error('Gmail API error:', e)
if (e.status === 403) {
throw createError({
statusCode: 403,
message: 'Gmail 접근 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
})
}
throw createError({
statusCode: 500,
message: '그룹 메시지를 가져오는데 실패했습니다.'
})
}
})
/**
* Google 토큰 갱신
*/
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
const config = useRuntimeConfig()
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
if (response.access_token) {
await execute(`
UPDATE wr_employee_info
SET google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
WHERE employee_id = $2
`, [response.access_token, employeeId])
return response.access_token
}
throw new Error('토큰 갱신 실패')
}

View File

@@ -0,0 +1,207 @@
import { queryOne, execute, query } from '../../utils/db'
import { requireAuth } from '../../utils/session'
/**
* 주간보고를 구글 그룹에 공유 (이메일 전송)
* POST /api/google-group/share-report
*
* Gmail API로 그룹에 이메일 전송
*
* Body:
* - reportId: 주간보고 ID
* - groupEmail: 그룹 이메일 주소
* - subject?: 이메일 제목 (기본값 자동 생성)
*/
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const body = await readBody(event)
const { reportId, groupEmail, subject } = body
if (!reportId || !groupEmail) {
throw createError({ statusCode: 400, message: '보고서 ID와 그룹 이메일이 필요합니다.' })
}
// 주간보고 조회
const report = await queryOne<any>(`
SELECT r.*, e.employee_name, e.employee_email, p.project_name
FROM wr_weekly_report r
JOIN wr_employee_info e ON r.employee_id = e.employee_id
LEFT JOIN wr_project_info p ON r.project_id = p.project_id
WHERE r.report_id = $1
`, [reportId])
if (!report) {
throw createError({ statusCode: 404, message: '주간보고를 찾을 수 없습니다.' })
}
// 권한 확인 (본인 보고서만)
if (report.employee_id !== session.employeeId) {
throw createError({ statusCode: 403, message: '본인의 주간보고만 공유할 수 있습니다.' })
}
// 사용자의 Google 토큰 조회
const employee = await queryOne<any>(`
SELECT google_access_token, google_refresh_token, google_token_expires_at, employee_email
FROM wr_employee_info WHERE employee_id = $1
`, [session.employeeId])
if (!employee?.google_access_token) {
throw createError({
statusCode: 401,
message: 'Google 계정이 연결되지 않았습니다.'
})
}
let accessToken = employee.google_access_token
// 토큰 만료 확인 및 갱신
if (employee.google_token_expires_at && new Date(employee.google_token_expires_at) < new Date()) {
accessToken = await refreshGoogleToken(session.employeeId, employee.google_refresh_token)
}
// 이메일 내용 생성
const emailSubject = subject || `[주간보고] ${report.project_name || '개인'} - ${report.report_week}주차 (${report.employee_name})`
const emailBody = generateReportEmailBody(report)
// RFC 2822 형식의 이메일 메시지 생성
const emailLines = [
`From: ${employee.employee_email}`,
`To: ${groupEmail}`,
`Subject: =?UTF-8?B?${Buffer.from(emailSubject).toString('base64')}?=`,
'MIME-Version: 1.0',
'Content-Type: text/html; charset=UTF-8',
'',
emailBody
]
const rawEmail = Buffer.from(emailLines.join('\r\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
try {
// Gmail API로 이메일 전송
const response = await $fetch<any>('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: { raw: rawEmail }
})
// 공유 이력 저장
await execute(`
INSERT INTO wr_report_share_log (report_id, shared_to, shared_type, shared_by, message_id)
VALUES ($1, $2, 'GOOGLE_GROUP', $3, $4)
`, [reportId, groupEmail, session.employeeId, response.id])
return {
success: true,
message: `${groupEmail}로 주간보고가 공유되었습니다.`,
messageId: response.id
}
} catch (e: any) {
console.error('Gmail send error:', e)
if (e.status === 403) {
throw createError({
statusCode: 403,
message: 'Gmail 발송 권한이 없습니다. Google 로그인 시 권한을 허용해주세요.'
})
}
throw createError({
statusCode: 500,
message: '이메일 발송에 실패했습니다.'
})
}
})
/**
* 주간보고 이메일 본문 생성
*/
function generateReportEmailBody(report: any): string {
const weekRange = `${report.week_start_date?.split('T')[0] || ''} ~ ${report.week_end_date?.split('T')[0] || ''}`
return `
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: 'Malgun Gothic', sans-serif; padding: 20px; max-width: 800px;">
<h2 style="color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px;">
📋 주간업무보고
</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr>
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>작성자</strong></td>
<td style="padding: 8px;">${report.employee_name}</td>
<td style="padding: 8px; background: #f8f9fa; width: 120px;"><strong>프로젝트</strong></td>
<td style="padding: 8px;">${report.project_name || '-'}</td>
</tr>
<tr>
<td style="padding: 8px; background: #f8f9fa;"><strong>보고 주차</strong></td>
<td style="padding: 8px;">${report.report_year}${report.report_week}주차</td>
<td style="padding: 8px; background: #f8f9fa;"><strong>기간</strong></td>
<td style="padding: 8px;">${weekRange}</td>
</tr>
</table>
<h3 style="color: #28a745;">✅ 금주 실적</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.this_week_work || '(내용 없음)'}</div>
<h3 style="color: #007bff;">📅 차주 계획</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.next_week_plan || '(내용 없음)'}</div>
${report.issues ? `
<h3 style="color: #dc3545;">⚠️ 이슈사항</h3>
<div style="background: #fff3cd; padding: 15px; border-radius: 5px; margin-bottom: 20px; white-space: pre-wrap;">${report.issues}</div>
` : ''}
${report.remarks ? `
<h3 style="color: #6c757d;">📝 비고</h3>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; white-space: pre-wrap;">${report.remarks}</div>
` : ''}
<hr style="margin-top: 30px; border: none; border-top: 1px solid #ddd;">
<p style="color: #6c757d; font-size: 12px;">
이 메일은 주간업무보고 시스템에서 자동 발송되었습니다.
</p>
</body>
</html>
`.trim()
}
/**
* Google 토큰 갱신
*/
async function refreshGoogleToken(employeeId: number, refreshToken: string): Promise<string> {
const config = useRuntimeConfig()
const response = await $fetch<any>('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token'
}).toString(),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
if (response.access_token) {
await execute(`
UPDATE wr_employee_info
SET google_access_token = $1,
google_token_expires_at = NOW() + INTERVAL '${response.expires_in} seconds'
WHERE employee_id = $2
`, [response.access_token, employeeId])
return response.access_token
}
throw new Error('토큰 갱신 실패')
}