diff --git a/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java index 5910407..ec5b3be 100644 --- a/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java +++ b/batch-quartz/src/main/java/com/spring/common/error/BizBaseException.java @@ -8,8 +8,8 @@ public class BizBaseException extends RuntimeException { private final ErrorRule errorRule; public BizBaseException() { - super(ExceptionRule.SYSTE_ERROR.getMessage()); - this.errorRule = ExceptionRule.SYSTE_ERROR; + super(ExceptionRule.SYSTEM_ERROR.getMessage()); + this.errorRule = ExceptionRule.SYSTEM_ERROR; } public BizBaseException(ErrorRule exceptionRule) { diff --git a/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java b/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java index 979657b..b158296 100644 --- a/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java +++ b/batch-quartz/src/main/java/com/spring/common/error/ExceptionRule.java @@ -9,7 +9,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public enum ExceptionRule implements ErrorRule { - SYSTE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "시스템 오류 입니다."), + SYSTEM_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "시스템 오류 입니다."), BAD_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "접근이 금지되었습니다."), diff --git a/batch-quartz/src/main/java/com/spring/domain/user/api/PasswordChangeApi.java b/batch-quartz/src/main/java/com/spring/domain/user/api/PasswordChangeApi.java new file mode 100644 index 0000000..1b24197 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/api/PasswordChangeApi.java @@ -0,0 +1,27 @@ +package com.spring.domain.user.api; + +import javax.validation.Valid; + +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.spring.domain.user.dto.PasswordChangeRequest; +import com.spring.domain.user.service.PasswordChangeService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +public class PasswordChangeApi { + + private final PasswordChangeService passwordChangeService; + + @PostMapping("/password-change") + public void passwordChange(@RequestBody @Valid PasswordChangeRequest request) { + passwordChangeService.changePassword(request); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/dto/PasswordChangeRequest.java b/batch-quartz/src/main/java/com/spring/domain/user/dto/PasswordChangeRequest.java new file mode 100644 index 0000000..566cbac --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/dto/PasswordChangeRequest.java @@ -0,0 +1,18 @@ +package com.spring.domain.user.dto; + +import javax.validation.constraints.NotBlank; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class PasswordChangeRequest { + + @NotBlank(message = "사용자ID는 필수값 입니다.") + private final String userId; + + @NotBlank(message = "비밀번호는 필수값 입니다.") + private final String newPassword; + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/entity/AgentUser.java b/batch-quartz/src/main/java/com/spring/domain/user/entity/AgentUser.java index 4645f76..1dd68df 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/entity/AgentUser.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/entity/AgentUser.java @@ -57,6 +57,10 @@ public class AgentUser implements UserPrincipal { this.userRole = userRole; } + public void changePassword(String newPassword) { + this.userPassword = newPassword; + } + @Override public Collection extends GrantedAuthority> getAuthorities() { return Arrays.stream(AgentUserRole.values()) diff --git a/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordMismatchException.java b/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordMismatchException.java new file mode 100644 index 0000000..7120881 --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordMismatchException.java @@ -0,0 +1,11 @@ +package com.spring.domain.user.error; + +import com.spring.common.error.BizBaseException; + +public class PasswordMismatchException extends BizBaseException { + + public PasswordMismatchException() { + super(UserRule.CURRENT_PASSWORD_MISMATCH); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordSameException.java b/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordSameException.java new file mode 100644 index 0000000..f9736ff --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/error/PasswordSameException.java @@ -0,0 +1,11 @@ +package com.spring.domain.user.error; + +import com.spring.common.error.BizBaseException; + +public class PasswordSameException extends BizBaseException { + + public PasswordSameException() { + super(UserRule.NEW_PASSWORD_SAME_AS_CURRENT); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/error/UserRule.java b/batch-quartz/src/main/java/com/spring/domain/user/error/UserRule.java index 964124f..4941a85 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/error/UserRule.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/error/UserRule.java @@ -13,7 +13,9 @@ public enum UserRule implements ErrorRule { USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "사용자 인증에 실패 하였습니다."), - USER_ID_CONFLICT(HttpStatus.CONFLICT, "중복된 아이디 입니다."); + USER_ID_CONFLICT(HttpStatus.CONFLICT, "중복된 아이디 입니다."), + CURRENT_PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "현재 비밀번호가 일치하지 않습니다."), + NEW_PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "새 비밀번호는 현재 비밀번호와 달라야 합니다."); private final HttpStatus status; private String message; diff --git a/batch-quartz/src/main/java/com/spring/domain/user/service/PasswordChangeService.java b/batch-quartz/src/main/java/com/spring/domain/user/service/PasswordChangeService.java new file mode 100644 index 0000000..2be635e --- /dev/null +++ b/batch-quartz/src/main/java/com/spring/domain/user/service/PasswordChangeService.java @@ -0,0 +1,31 @@ +package com.spring.domain.user.service; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.spring.domain.user.dto.PasswordChangeRequest; +import com.spring.domain.user.entity.AgentUser; +import com.spring.domain.user.error.PasswordSameException; +import com.spring.domain.user.error.UserNotFoundException; +import com.spring.domain.user.repository.AgentUserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PasswordChangeService { + + private final AgentUserRepository agentUserRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional + public void changePassword(PasswordChangeRequest request) { + AgentUser user = agentUserRepository.findByUserId(request.getUserId()).orElseThrow(UserNotFoundException::new); + if (passwordEncoder.matches(request.getNewPassword(), user.getPassword())) { + throw new PasswordSameException(); + } + user.changePassword(passwordEncoder.encode(request.getNewPassword())); + } + +} diff --git a/batch-quartz/src/main/java/com/spring/domain/user/service/UserPrincipalService.java b/batch-quartz/src/main/java/com/spring/domain/user/service/UserPrincipalService.java index 301b691..cf9f725 100644 --- a/batch-quartz/src/main/java/com/spring/domain/user/service/UserPrincipalService.java +++ b/batch-quartz/src/main/java/com/spring/domain/user/service/UserPrincipalService.java @@ -8,9 +8,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.spring.domain.user.error.UserNotFoundException; -import com.spring.domain.user.error.UserRule; import com.spring.domain.user.repository.AgentUserRepository; import com.spring.infra.security.domain.UserPrincipal; +import com.spring.infra.security.error.SecurityAuthException; +import com.spring.infra.security.error.SecurityExceptionRule; import lombok.RequiredArgsConstructor; @@ -24,7 +25,7 @@ public class UserPrincipalService implements UserDetailsService { @Override public UserPrincipal loadUserByUsername(String username) throws UsernameNotFoundException { return agentUserRepository.findByUserId(username) - .orElseThrow(() -> new UsernameNotFoundException(UserRule.USER_UNAUTHORIZED.getMessage())); + .orElseThrow(() -> new SecurityAuthException(SecurityExceptionRule.USER_UNAUTHORIZED)); } @Transactional(readOnly = true) diff --git a/batch-quartz/src/main/java/com/spring/infra/security/config/PermittedURI.java b/batch-quartz/src/main/java/com/spring/infra/security/config/PermittedURI.java index 59e5223..7223d07 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/config/PermittedURI.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/config/PermittedURI.java @@ -12,6 +12,7 @@ public enum PermittedURI { FAVICON_URI("/favicon.ico"), USER_CONFLICT_URI("/api/user/conflict/{userId}"), USER_SIGN_UP("/api/user/sign-up"), + PASSWOD_CHANGE("/api/user/password-change"), USER_SIGN_IN("/sign-in"), USER_SIGN_OUT("/sign-out"); diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java index 5cfe868..70f8c6e 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityAuthException.java @@ -11,7 +11,7 @@ public class SecurityAuthException extends AuthenticationException { public SecurityAuthException(String msg) { super(msg); - this.exceptionRule = null; + this.exceptionRule = SecurityExceptionRule.SYSTEM_ERROR; } public SecurityAuthException(SecurityExceptionRule exceptionRule) { diff --git a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java index 0300e39..2206c9a 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/error/SecurityExceptionRule.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor public enum SecurityExceptionRule implements ErrorRule { + SYSTEM_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "시스템 오류 입니다."), UNSUPPORTED_MEDIA_ERROR(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원되지 않는 유형 입니다."), USER_BAD_REQUEST(HttpStatus.BAD_REQUEST, "사용자 정보가 올바르지 않습니다."), USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "사용자 인증에 실패 하였습니다."), diff --git a/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java b/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java index bacf88e..646d1fe 100644 --- a/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java +++ b/batch-quartz/src/main/java/com/spring/infra/security/provider/UserAuthenticationProvider.java @@ -29,7 +29,7 @@ public class UserAuthenticationProvider implements AuthenticationProvider { String password = String.valueOf(authentication.getCredentials()); UserPrincipal user = (UserPrincipal) userDetailsService.loadUserByUsername(username); if (isNotMatches(password, user.getPassword())) { - throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_PASSWORD.getMessage()); + throw new SecurityAuthException(SecurityExceptionRule.USER_NOT_PASSWORD); } return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities()); } diff --git a/batch-quartz/src/main/resources/static/js/apis/sign-api.js b/batch-quartz/src/main/resources/static/js/apis/sign-api.js index 88ed342..227f2ad 100644 --- a/batch-quartz/src/main/resources/static/js/apis/sign-api.js +++ b/batch-quartz/src/main/resources/static/js/apis/sign-api.js @@ -22,6 +22,11 @@ const signService = { isConflictUserId: async (userId) => { const response = await apiClient.get(`/api/user/conflict/${userId}`); return response.data.data; + }, + + changePassword: async (userId, newPassword) => { + const response = await apiClient.post('/api/user/password-change', { userId, newPassword }); + return response.data.data; } }; diff --git a/batch-quartz/src/main/resources/static/js/pages/dashboard/dashboard.js b/batch-quartz/src/main/resources/static/js/pages/dashboard/dashboard.js index 7e7c28b..adb846a 100644 --- a/batch-quartz/src/main/resources/static/js/pages/dashboard/dashboard.js +++ b/batch-quartz/src/main/resources/static/js/pages/dashboard/dashboard.js @@ -2,6 +2,7 @@ import { formatDateTime } from '../../common/common.js'; import dashBoardService from '../../apis/dashboard-api.js'; let selectedMonth; + document.addEventListener('DOMContentLoaded', () => { initMonthPicker(); fetchDataAndRender(); @@ -9,8 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { const initMonthPicker = () => { const monthPicker = document.getElementById('monthPicker'); - const currentDate = dayjs(); - selectedMonth = currentDate.format('YYYY-MM'); + selectedMonth = dayjs().format('YYYY-MM'); monthPicker.value = selectedMonth; monthPicker.addEventListener('click', (event) => { @@ -18,16 +18,18 @@ const initMonthPicker = () => { monthPicker.showPicker(); }); - monthPicker.addEventListener('change', (event) => { - selectedMonth = event.target.value; - if(selectedMonth) fetchDataAndRender(); + monthPicker.addEventListener('change', ({ target: { value } }) => { + selectedMonth = value; + if (selectedMonth) fetchDataAndRender(); }); }; const fetchDataAndRender = async () => { const [year, month] = selectedMonth.split('-'); - const batchData = await dashBoardService.getBatchJobExecutionData(year, month); - const recentJobs = await dashBoardService.getRecentJobs(); + const [batchData, recentJobs] = await Promise.all([ + dashBoardService.getBatchJobExecutionData(year, month), + dashBoardService.getRecentJobs() + ]); renderBatchExecutionTimeChart(batchData.jobAvgSummary); renderBatchStatusChart(batchData.statusCounts); @@ -40,37 +42,29 @@ const chartOptions = { responsive: true, maintainAspectRatio: false, plugins: { - legend: { - position: 'top', - }, - title: { - font: { - size: 16 - } - } + legend: { position: 'top' }, + title: { font: { size: 16 } } } }; let batchExecutionTimeChart; const renderBatchExecutionTimeChart = (data) => { if (batchExecutionTimeChart) batchExecutionTimeChart.destroy(); - const jobExecutionTimes = {}; - data.forEach(job => { - if (job.endTime && job.startTime) { - const duration = (new Date(job.endTime) - new Date(job.startTime)) / 1000; // 초 단위 - if (!jobExecutionTimes[job.jobName]) { - jobExecutionTimes[job.jobName] = { total: 0, count: 0 }; - } - jobExecutionTimes[job.jobName].total += duration; - jobExecutionTimes[job.jobName].count++; - } - }); - const averageExecutionTimes = Object.entries(jobExecutionTimes).reduce((acc, [jobName, job]) => { - acc[jobName] = job.total / job.count; + const jobExecutionTimes = data.reduce((acc, job) => { + if (job.endTime && job.startTime) { + const duration = (new Date(job.endTime) - new Date(job.startTime)) / 1000; + if (!acc[job.jobName]) acc[job.jobName] = { total: 0, count: 0 }; + acc[job.jobName].total += duration; + acc[job.jobName].count++; + } return acc; }, {}); + const averageExecutionTimes = Object.fromEntries( + Object.entries(jobExecutionTimes).map(([jobName, { total, count }]) => [jobName, total / count]) + ); + const ctx = document.getElementById('batchExecutionTimeChart').getContext('2d'); batchExecutionTimeChart = new Chart(ctx, { type: 'bar', @@ -90,21 +84,14 @@ const renderBatchExecutionTimeChart = (data) => { ...chartOptions.plugins, tooltip: { callbacks: { - label: (context) => { - const label = context.dataset.label || ''; - const value = context.parsed.y.toFixed(2); - return `${label}: ${value} 초`; - } + label: (context) => `${context.dataset.label || ''}: ${context.parsed.y.toFixed(2)} 초` } } }, scales: { y: { beginAtZero: true, - title: { - display: true, - text: '시간 (초)' - } + title: { display: true, text: '시간 (초)' } } } } @@ -114,16 +101,12 @@ const renderBatchExecutionTimeChart = (data) => { let batchStatusChart; const renderBatchStatusChart = (data) => { if (batchStatusChart) batchStatusChart.destroy(); - const statusTotals = data.reduce((acc, item) => { - if (!acc[item.status]) { - acc[item.status] = 0; - } - acc[item.status] += item.count; + + const statusTotals = data.reduce((acc, { status, count }) => { + acc[status] = (acc[status] || 0) + count; return acc; }, {}); - const labels = Object.keys(statusTotals); - const counts = Object.values(statusTotals); const statusColors = { 'COMPLETED': 'rgba(75, 192, 192, 0.6)', 'FAILED': 'rgba(255, 99, 132, 0.6)', @@ -138,9 +121,9 @@ const renderBatchStatusChart = (data) => { batchStatusChart = new Chart(ctx, { type: 'pie', data: { - labels: labels, + labels: Object.keys(statusTotals), datasets: [{ - data: counts, + data: Object.values(statusTotals), backgroundColor: Object.values(statusColors) }] }, @@ -151,18 +134,11 @@ const renderBatchStatusChart = (data) => { tooltip: { callbacks: { title: () => null, - label: (context) => { - const label = context.label || ''; - const value = context.parsed || 0; - return `${label}: ${value}`; - }, - afterLabel: (context) => { - const status = context.label; - return data - .filter(item => item.status === status) - .map(item => ` ${item.jobName}: ${item.count}`) - .join('\n'); - } + label: ({ label, parsed }) => `${label}: ${parsed}`, + afterLabel: ({ label }) => data + .filter(item => item.status === label) + .map(item => ` ${item.jobName}: ${item.count}`) + .join('\n') } } } @@ -173,53 +149,33 @@ const renderBatchStatusChart = (data) => { let hourlyJobExecutionChart; const renderHourlyJobExecutionChart = (data) => { if (hourlyJobExecutionChart) hourlyJobExecutionChart.destroy(); + const ctx = document.getElementById('hourlyJobExecutionChart').getContext('2d'); - const hours = Array.from({length: 24}, (_, i) => i); + const hours = Array.from({ length: 24 }, (_, i) => i); const jobNames = [...new Set(data.map(item => item.jobName))]; - const datasets = jobNames.map(jobName => { - const jobData = hours.map(hour => { - const hourData = data.find(item => item.jobName === jobName && item.hour === hour); - return hourData ? hourData.count : 0; - }); - - return { - label: jobName, - data: jobData, - borderColor: getRandomColor(), - backgroundColor: 'rgba(0, 0, 0, 0.1)', - fill: false - }; - }); + + const datasets = jobNames.map(jobName => ({ + label: jobName, + data: hours.map(hour => data.find(item => item.jobName === jobName && item.hour === hour)?.count || 0), + borderColor: getRandomColor(), + backgroundColor: 'rgba(0, 0, 0, 0.1)', + fill: false + })); hourlyJobExecutionChart = new Chart(ctx, { type: 'line', - data: { - labels: hours.map(hour => `${hour}:00`), - datasets: datasets - }, + data: { labels: hours.map(hour => `${hour}:00`), datasets }, options: { ...chartOptions, plugins: { ...chartOptions.plugins, - tooltip: { - mode: 'index', - intersect: false, - } + tooltip: { mode: 'index', intersect: false } }, scales: { - x: { - display: true, - title: { - display: true, - text: '시간' - } - }, + x: { display: true, title: { display: true, text: '시간' } }, y: { display: true, - title: { - display: true, - text: '실행 횟수' - }, + title: { display: true, text: '실행 횟수' }, suggestedMin: 0, beginAtZero: true } @@ -231,33 +187,26 @@ const renderHourlyJobExecutionChart = (data) => { let dailyJobExecutionsChart; const renderDailyJobExecutionsChart = (data) => { if (dailyJobExecutionsChart) dailyJobExecutionsChart.destroy(); + const ctx = document.getElementById('dailyJobExecutionsChart').getContext('2d'); - const [year, month] = selectedMonth.split('-'); const firstDay = new Date(year, month - 1, 1); const lastDay = new Date(year, month, 0); - const endDate = new Date(lastDay.getTime()); - - const dates = []; - for (let d = new Date(firstDay); d <= endDate; d = new Date(d.setDate(d.getDate() + 1))) { - dates.push(d.toISOString().split('T')[0]); - } + + const dates = Array.from({ length: lastDay.getDate() }, (_, i) => + new Date(year, month - 1, i + 1).toISOString().split('T')[0] + ); const groupedData = data.reduce((acc, item) => { const date = item.executionDate.split('T')[0]; - if (!acc[date]) { - acc[date] = {}; - } - if (!acc[date][item.jobName]) { - acc[date][item.jobName] = 0; - } - acc[date][item.jobName] += item.executionCount; + if (!acc[date]) acc[date] = {}; + acc[date][item.jobName] = (acc[date][item.jobName] || 0) + item.executionCount; return acc; }, {}); const jobNames = [...new Set(data.map(item => item.jobName))]; - const datasets = jobNames.map((jobName) => { + const datasets = jobNames.map(jobName => { const color = getRandomColor(); return { label: jobName, @@ -274,43 +223,28 @@ const renderDailyJobExecutionsChart = (data) => { dailyJobExecutionsChart = new Chart(ctx, { type: 'line', - data: { - datasets: datasets - }, + data: { datasets }, options: { ...chartOptions, plugins: { ...chartOptions.plugins, tooltip: { callbacks: { - title: (context) => { - return luxon.DateTime.fromJSDate(context[0].parsed.x).toFormat('yyyy-MM-dd'); - } + title: (context) => luxon.DateTime.fromJSDate(context[0].parsed.x).toFormat('yyyy-MM-dd') } } }, scales: { x: { type: 'time', - time: { - unit: 'day', - displayFormats: { - day: 'MM-dd' - } - }, - title: { - display: true, - text: '날짜' - }, + time: { unit: 'day', displayFormats: { day: 'MM-dd' } }, + title: { display: true, text: '날짜' }, min: firstDay, max: lastDay }, y: { beginAtZero: true, - title: { - display: true, - text: '실행 횟수' - } + title: { display: true, text: '실행 횟수' } } } } @@ -318,20 +252,18 @@ const renderDailyJobExecutionsChart = (data) => { }; const renderRecentJobsTable = (recentJobs) => { - const tableBody = document.getElementById('recentJobsTable'); - tableBody.innerHTML = recentJobs.map(job => ` -