관리자 모드
1. 관리자 페이지
1. 관리자는 DB에 수동 등록
- 회원 등록, DB에서 수동으로 Role을 ADMIN으로 변경
2. 예약 조회, 관리 - 관리자 기능
- 날짜 선택, 예약 조회, 예약 승인/거부
- 예약 승인/거부는 관리자만 할 수 있음
- 관리자 기능은 관리자만 사용가능하도록 제한
3. 백엔드
- 컨트롤러에서 역할에 따라 접근 제어하기
- 예약 상태 변경 : 예약 승인 (APPROVED), 거절 (REJECTED)
- Jwt Token 생성할 때 이메일 뿐 아니라 역할(Role)도 클레임에 포함
- Jwt Token에서 역할 추출 메서드 추가
- 로그인할 때 관리자, 사용자 구분 가능
- SecurityFilterChain에 관리자 전용 경로 설정
- /api/admin/** 경로는 ADMIN만 접근 가능하게 설정
- 프론트엔드 -
AdminReservations.vue
<!-- src/views/AdminReservations.vue -->
<template>
<v-container>
<!-- 날짜 선택 -->
<v-menu
v-model="menu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="290px"
>
<template #activator="{ props }">
<v-text-field
v-model="selectedDate"
label="예약 날짜 선택"
prepend-icon="mdi-calendar"
readonly
v-bind="props"
></v-text-field>
</template>
<v-date-picker
v-model="selectedDate"
@update:modelValue="onDateChange"
/>
</v-menu>
<!-- 예약 카드 -->
<v-card class="mb-4" v-for="reservation in reservations" :key="reservation.id">
<v-card-title>
예약 ID: {{ reservation.id }} - {{ reservation.date }} - 상태: {{ statusText(reservation.status) }}
</v-card-title>
<v-card-text>
메모: {{ reservation.memo }}
<br />
시간 슬롯:
<ul>
<li v-for="slot in reservation.timeSlots" :key="slot.id">{{ slot.timeSlot }}</li>
</ul>
</v-card-text>
<v-card-actions>
<v-btn
color="green"
@click="updateStatus(reservation.id, 'APPROVED')"
:disabled="reservation.status !== 'PENDING'"
>
승인
</v-btn>
<v-btn
color="red"
@click="updateStatus(reservation.id, 'REJECTED')"
:disabled="reservation.status !== 'PENDING'"
>
거부
</v-btn>
</v-card-actions>
</v-card>
</v-container>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useReservationStore } from '@/stores/reservationStore'
const reservationStore = useReservationStore()
const reservations = ref([])
const selectedDate = ref('')
const menu = ref(false)
const statusText = (status) => {
const map = {
PENDING: '대기 중',
APPROVED: '승인',
REJECTED: '거부',
CANCELLED: '취소됨'
};
return map[status] || '알 수 없음';
};
const fetchAllReservations = async () => {
try {
if (!selectedDate.value) {
reservations.value = []
return
}
// 날짜 필터를 서버 API 호출에 전달
const res = await reservationStore.fetchReservations(selectedDate.value)
reservations.value = res
} catch (err) {
console.error('예약 목록 조회 실패', err)
}
}
// 날짜가 바뀔 때마다 예약 목록 다시 조회
const formatDateTime = (value) => {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const onDateChange = (date) => {
selectedDate.value = formatDateTime(date)
menu.value = false // 달력 닫기
fetchAllReservations()
}
// ✅ 예약 상태 업데이트
const updateStatus = async (reservationId, newStatus) => {
try {
await reservationStore.updateStatus(reservationId, newStatus)
await fetchAllReservations() // 변경 후 목록 다시 불러오기
} catch (err) {
console.error('예약 상태 변경 실패', err)
alert('예약 상태 변경 중 오류가 발생했습니다.')
}
}
</script>
stores - reservationStore.js
// src/stores/reservationStore.js
import { defineStore } from 'pinia'
import axios from 'axios'
const API_URL = 'http://localhost:8080/api/auth'
const API_ADMIN_URL = 'http://localhost:8080/api/admin'
export const useReservationStore = defineStore('reservation', () => {
// 예약 제출 함수
const submitReservation = async (formData) => {
const token = localStorage.getItem('token') // 로그인 후 저장된 JWT
try {
const response = await axios.post(
`${API_URL}/reservation`,
formData,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
return response.data // 호출한 쪽에서 alert 등 처리 가능
} catch (err) {
// 에러를 그대로 던져서 컴포넌트에서 처리하도록
throw err
}
}
const fetchReservations = async (date) => {
try {
const token = localStorage.getItem('token')
if (!token) throw new Error('인증 토큰이 없습니다.')
const params = date ? { date } : {}
const response = await axios.get(`${API_ADMIN_URL}/reservations`, {
headers: {
Authorization: `Bearer ${token}`,
},
params,
})
return response.data
} catch (err) {
console.error('예약 목록 조회 실패:', err)
throw err
}
}
const updateStatus = async (id, status) => {
try {
const token = localStorage.getItem('token')
const endpoint =
status === 'APPROVED'
? `${API_ADMIN_URL}/reservation/${id}/approve`
: `${API_ADMIN_URL}/reservation/${id}/reject`
await axios.post(endpoint, null, {
headers: {
Authorization: `Bearer ${token}`,
},
})
await fetchReservations() // 상태 업데이트 후 다시 불러오기
} catch (err) {
console.error('상태 변경 실패', err)
}
}
return {
submitReservation,
fetchReservations,
updateStatus,
}
})
- 백엔드 -
JwtUtil.java
package com.example.reservation.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
import java.util.List;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
// 토큰 생성 시 역할 포함
public String generateToken(String email, String role) {
Date now = new Date();
Date expiry = new Date(now.getTime() + expiration);
Key key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.builder()
.setSubject(email)
.claim("role", role) // 역할 추가
.setIssuedAt(now)
.setExpiration(expiry)
.signWith(key)
.compact();
}
// 토큰에서 사용자 이메일 추출
public String extractUsername(String token) {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
// 토큰에서 역할 목록 추출
@SuppressWarnings("unchecked")
public List<String> extractRoles(String token) {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Object roles = claims.get("role");
if (roles instanceof List<?>) {
return (List<String>) roles;
}
return List.of();
}
// 토큰 유효성 검사
public boolean isTokenValid(String token) {
try {
Key key = Keys.hmacShaKeyFor(secret.getBytes());
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (JwtException e) {
return false;
}
}
}
SecurityConfig.java
package com.example.reservation.config;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import java.util.List;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(request -> {
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.setAllowedOrigins(List.of("http://localhost:5173"));
corsConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
corsConfig.setAllowedHeaders(List.of("*"));
corsConfig.setAllowCredentials(true);
return corsConfig;
}))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // OPTIONS 요청 허용
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config
) throws Exception {
return config.getAuthenticationManager();
}
}
AuthController.java
package com.example.reservation.controller;
import com.example.reservation.dto.*;
import com.example.reservation.model.User;
import com.example.reservation.repository.UserRepository;
import com.example.reservation.util.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@CrossOrigin(origins = "http://localhost:5173")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
@PostMapping("/register")
public ResponseEntity<ApiResponse> register(@RequestBody @Valid RegisterRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiResponse(false, "Email already registered"));
}
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setName(request.getName());
user.setRole("USER");
userRepository.save(user);
return ResponseEntity.ok(new ApiResponse(true, "User registered successfully"));
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody AuthRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("Invalid email or password"));
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new BadCredentialsException("Invalid email or password");
}
// role 포함해서 토큰 생성
String token = jwtUtil.generateToken(user.getEmail(), user.getRole());
AuthResponse response = new AuthResponse(token, user.getName(), user.getRole());
return ResponseEntity.ok(response);
}
}
ReservationController.java
package com.example.reservation.controller;
import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.service.ReservationService;
import com.example.reservation.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/")
@CrossOrigin(origins = "http://localhost:5173")
@RequiredArgsConstructor
public class ReservationController {
private static final Logger logger = LoggerFactory.getLogger(ReservationController.class);
private final ReservationService reservationService;
private final JwtUtil jwtUtil;
@PostMapping("auth/reservation")
public ReservationResponse create(
@RequestBody ReservationRequest request,
@RequestHeader("Authorization") String authHeader
) {
String token = authHeader.replace("Bearer ", "");
String email = jwtUtil.extractUsername(token);
return reservationService.createReservation(email, request);
}
/**
* ✅ 관리자: 전체 예약 목록 조회
*/
@GetMapping("admin/reservations")
@PreAuthorize("hasRole('ADMIN')")
public List<Reservation> getAllReservations(
@RequestParam(required = false) String date
) {
if (date != null && !date.isEmpty()) {
return reservationService.getReservationsByDate(date);
}
return reservationService.getAllReservations();
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("admin/reservation/{id}/approve")
public ResponseEntity<?> approveReservation(@PathVariable Long id) {
logger.info("==== approveReservation : " + id);
reservationService.updateReservationStatus(id, ReservationStatus.APPROVED);
return ResponseEntity.ok("Reservation approved");
}
@PreAuthorize("hasRole('ADMIN')")
@PostMapping("admin/reservation/{id}/reject")
public ResponseEntity<?> rejectReservation(@PathVariable Long id) {
logger.info("==== rejectReservation : " + id);
reservationService.updateReservationStatus(id, ReservationStatus.REJECTED);
return ResponseEntity.ok("Reservation rejected");
}
}
ReservationRepository.java
package com.example.reservation.repository;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.User;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
// JpaRepository를 상속받으면 save() 등 기본 CRUD 메서드 사용 가능
// 특정 사용자에 대한 예약 목록 조회
List<Reservation> findByUser(User user);
// 날짜 기준 예약 목록 조회
List<Reservation> findByDate(LocalDate date);
}
ReservationService.java
package com.example.reservation.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.example.reservation.dto.ReservationRequest;
import com.example.reservation.dto.ReservationResponse;
import com.example.reservation.model.Reservation;
import com.example.reservation.model.ReservationStatus;
import com.example.reservation.model.ReservationTimeSlot;
import com.example.reservation.model.User;
import com.example.reservation.repository.ReservationRepository;
import com.example.reservation.repository.UserRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class ReservationService {
private final ReservationRepository reservationRepository;
private final UserRepository userRepository;
/**
* 사용자: 예약 생성
*/
@Transactional
public ReservationResponse createReservation(String email, ReservationRequest request) {
User user = userRepository.findByEmail(email).orElseThrow();
Reservation reservation = new Reservation();
reservation.setDate(LocalDate.parse(request.getDate()));
reservation.setUser(user);
reservation.setMemo(request.getMemo());
reservation.setStatus(ReservationStatus.PENDING);
reservation.setCreatedAt(LocalDateTime.now());
List<ReservationTimeSlot> slotEntities = request.getTimeSlots().stream()
.map(ts -> {
ReservationTimeSlot slot = new ReservationTimeSlot();
slot.setTimeSlot(ts);
slot.setReservation(reservation); // 필수
return slot;
}).collect(Collectors.toList());
reservation.setTimeSlots(slotEntities);
Reservation saved = reservationRepository.save(reservation);
return new ReservationResponse(
saved.getId(),
saved.getDate().toString(),
saved.getTimeSlots().stream().map(ReservationTimeSlot::getTimeSlot).toList(),
saved.getMemo(),
saved.getStatus().name(),
saved.getCreatedAt().toString()
);
}
/**
* 관리자: 예약 상태 변경 (APPROVED, REJECTED, CANCELLED 등)
*/
@Transactional
public Reservation updateReservationStatus(Long reservationId, ReservationStatus status) {
Reservation reservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new RuntimeException("Reservation not found"));
reservation.setStatus(status);
return reservationRepository.save(reservation);
}
/**
* 관리자: 전체 예약 목록 조회
*/
public List<Reservation> getAllReservations() {
return reservationRepository.findAll();
}
public List<Reservation> getReservationsByDate(String date) {
// date 형식에 맞게 파싱 후 DB 쿼리 (예: '2025-05-27')
LocalDate localDate = LocalDate.parse(date); // 문자열 → LocalDate 변환
return reservationRepository.findByDate(localDate);
}
/**
* 사용자: 내 예약 목록 조회
*/
public List<Reservation> getReservationsByUser(String email) {
User user = userRepository.findByEmail(email).orElseThrow();
return reservationRepository.findByUser(user);
}
}
'예약 관리 시스템 - Spring + Vue' 카테고리의 다른 글
예약 관리 시스템 완성 - Spring Boot 3 + Vue 3 (0) | 2025.05.29 |
---|---|
예약 관리 시스템 (Spring Boot 3 + Vue 3) - 예약 조회(사용자) (0) | 2025.05.29 |
예약 관리 시스템 (Spring Boot 3 + Vue 3) - 예약 하기 (백엔드) (0) | 2025.05.26 |
예약 관리 시스템 (Spring Boot 3 + Vue 3) - 예약 하기 (프론트엔드) (0) | 2025.05.26 |
예약 관리 시스템 (Spring Boot 3 + Vue 3) - 사용자 인증 프론트엔드 (Vue 3 + Vuetify) (0) | 2025.05.26 |